From 9d3e653ec9d262fa34d6c63ab0ed2239d19895b1 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 15 Mar 2026 04:30:07 -0700 Subject: [PATCH] fix(web): handle 515 Stream Error during WhatsApp QR pairing (#27910) * fix(web): handle 515 Stream Error during WhatsApp QR pairing getStatusCode() never unwrapped the lastDisconnect wrapper object, so login.errorStatus was always undefined and the 515 restart path in restartLoginSocket was dead code. - Add err.error?.output?.statusCode fallback to getStatusCode() - Export waitForCredsSaveQueue() so callers can await pending creds - Await creds flush in restartLoginSocket before creating new socket Fixes #3942 * test: update session mock for getStatusCode unwrap + waitForCredsSaveQueue Mirror the getStatusCode fix (err.error?.output?.statusCode fallback) in the test mock and export waitForCredsSaveQueue so restartLoginSocket tests work correctly. * fix(web): scope creds save queue per-authDir to avoid cross-account blocking The credential save queue was a single global promise chain shared by all WhatsApp accounts. In multi-account setups, a slow save on one account blocked credential writes and 515 restart recovery for unrelated accounts. Replace the global queue with a per-authDir Map so each account's creds serialize independently. waitForCredsSaveQueue() now accepts an optional authDir to wait on a single account's queue, or waits on all when omitted. Co-Authored-By: Claude Opus 4.6 * test: use real Baileys v7 error shape in 515 restart test The test was using { output: { statusCode: 515 } } which was already handled before the fix. Updated to use the actual Baileys v7 shape { error: { output: { statusCode: 515 } } } to cover the new fallback path in getStatusCode. Co-Authored-By: Claude Code (Opus 4.6) * fix(web): bound credential-queue wait during 515 restart Prevents restartLoginSocket from blocking indefinitely if a queued saveCreds() promise stalls (e.g. hung filesystem write). Co-Authored-By: Claude * fix: clear flush timeout handle and assert creds queue in test Co-Authored-By: Claude * fix: evict settled credsSaveQueues entries to prevent unbounded growth Co-Authored-By: Claude * fix: share WhatsApp 515 creds flush handling (#27910) (thanks @asyncjason) --------- Co-authored-by: Jason Separovic Co-authored-by: Claude Opus 4.6 Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + extensions/whatsapp/src/login-qr.test.ts | 37 ++++++++++-- extensions/whatsapp/src/login-qr.ts | 4 +- .../whatsapp/src/login.coverage.test.ts | 39 ++++++++++++- extensions/whatsapp/src/login.ts | 18 +++--- extensions/whatsapp/src/session.test.ts | 56 +++++++++++++++++++ extensions/whatsapp/src/session.ts | 40 ++++++++++++- 7 files changed, 177 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0bcd97486..023d9edea79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai - Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason. - Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava. - WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT. +- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason. ### Fixes diff --git a/extensions/whatsapp/src/login-qr.test.ts b/extensions/whatsapp/src/login-qr.test.ts index 4b16a289001..48709ceb484 100644 --- a/extensions/whatsapp/src/login-qr.test.ts +++ b/extensions/whatsapp/src/login-qr.test.ts @@ -1,6 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js"; -import { createWaSocket, logoutWeb, waitForWaConnection } from "./session.js"; +import { + createWaSocket, + logoutWeb, + waitForCredsSaveQueueWithTimeout, + waitForWaConnection, +} from "./session.js"; vi.mock("./session.js", () => { const createWaSocket = vi.fn( @@ -17,11 +22,13 @@ vi.mock("./session.js", () => { const getStatusCode = vi.fn( (err: unknown) => (err as { output?: { statusCode?: number } })?.output?.statusCode ?? - (err as { status?: number })?.status, + (err as { status?: number })?.status ?? + (err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode, ); const webAuthExists = vi.fn(async () => false); const readWebSelfId = vi.fn(() => ({ e164: null, jid: null })); const logoutWeb = vi.fn(async () => true); + const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {}); return { createWaSocket, waitForWaConnection, @@ -30,6 +37,7 @@ vi.mock("./session.js", () => { webAuthExists, readWebSelfId, logoutWeb, + waitForCredsSaveQueueWithTimeout, }; }); @@ -39,22 +47,43 @@ vi.mock("./qr-image.js", () => ({ const createWaSocketMock = vi.mocked(createWaSocket); const waitForWaConnectionMock = vi.mocked(waitForWaConnection); +const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout); const logoutWebMock = vi.mocked(logoutWeb); +async function flushTasks() { + await Promise.resolve(); + await Promise.resolve(); +} + describe("login-qr", () => { beforeEach(() => { vi.clearAllMocks(); }); it("restarts login once on status 515 and completes", async () => { + let releaseCredsFlush: (() => void) | undefined; + const credsFlushGate = new Promise((resolve) => { + releaseCredsFlush = resolve; + }); waitForWaConnectionMock - .mockRejectedValueOnce({ output: { statusCode: 515 } }) + // Baileys v7 wraps the error: { error: BoomError(515) } + .mockRejectedValueOnce({ error: { output: { statusCode: 515 } } }) .mockResolvedValueOnce(undefined); + waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate); const start = await startWebLoginWithQr({ timeoutMs: 5000 }); expect(start.qrDataUrl).toBe("data:image/png;base64,base64"); - const result = await waitForWebLogin({ timeoutMs: 5000 }); + const resultPromise = waitForWebLogin({ timeoutMs: 5000 }); + await flushTasks(); + await flushTasks(); + + expect(createWaSocketMock).toHaveBeenCalledTimes(1); + expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce(); + expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(expect.any(String)); + + releaseCredsFlush?.(); + const result = await resultPromise; expect(result.connected).toBe(true); expect(createWaSocketMock).toHaveBeenCalledTimes(2); diff --git a/extensions/whatsapp/src/login-qr.ts b/extensions/whatsapp/src/login-qr.ts index a54e3fe56b2..3681d646252 100644 --- a/extensions/whatsapp/src/login-qr.ts +++ b/extensions/whatsapp/src/login-qr.ts @@ -12,6 +12,7 @@ import { getStatusCode, logoutWeb, readWebSelfId, + waitForCredsSaveQueueWithTimeout, waitForWaConnection, webAuthExists, } from "./session.js"; @@ -85,9 +86,10 @@ async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) { } login.restartAttempted = true; runtime.log( - info("WhatsApp asked for a restart after pairing (code 515); retrying connection once…"), + info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"), ); closeSocket(login.sock); + await waitForCredsSaveQueueWithTimeout(login.authDir); try { const sock = await createWaSocket(false, login.verbose, { authDir: login.authDir, diff --git a/extensions/whatsapp/src/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts index 6306228693a..dda665ccdce 100644 --- a/extensions/whatsapp/src/login.coverage.test.ts +++ b/extensions/whatsapp/src/login.coverage.test.ts @@ -4,7 +4,12 @@ import path from "node:path"; import { DisconnectReason } from "@whiskeysockets/baileys"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loginWeb } from "./login.js"; -import { createWaSocket, formatError, waitForWaConnection } from "./session.js"; +import { + createWaSocket, + formatError, + waitForCredsSaveQueueWithTimeout, + waitForWaConnection, +} from "./session.js"; const rmMock = vi.spyOn(fs, "rm"); @@ -35,10 +40,19 @@ vi.mock("./session.js", () => { const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB)); const waitForWaConnection = vi.fn(); const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`); + const getStatusCode = vi.fn( + (err: unknown) => + (err as { output?: { statusCode?: number } })?.output?.statusCode ?? + (err as { status?: number })?.status ?? + (err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode, + ); + const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {}); return { createWaSocket, waitForWaConnection, formatError, + getStatusCode, + waitForCredsSaveQueueWithTimeout, WA_WEB_AUTH_DIR: authDir, logoutWeb: vi.fn(async (params: { authDir?: string }) => { await fs.rm(params.authDir ?? authDir, { @@ -52,8 +66,14 @@ vi.mock("./session.js", () => { const createWaSocketMock = vi.mocked(createWaSocket); const waitForWaConnectionMock = vi.mocked(waitForWaConnection); +const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout); const formatErrorMock = vi.mocked(formatError); +async function flushTasks() { + await Promise.resolve(); + await Promise.resolve(); +} + describe("loginWeb coverage", () => { beforeEach(() => { vi.useFakeTimers(); @@ -65,12 +85,25 @@ describe("loginWeb coverage", () => { }); it("restarts once when WhatsApp requests code 515", async () => { + let releaseCredsFlush: (() => void) | undefined; + const credsFlushGate = new Promise((resolve) => { + releaseCredsFlush = resolve; + }); waitForWaConnectionMock - .mockRejectedValueOnce({ output: { statusCode: 515 } }) + .mockRejectedValueOnce({ error: { output: { statusCode: 515 } } }) .mockResolvedValueOnce(undefined); + waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate); const runtime = { log: vi.fn(), error: vi.fn() } as never; - await loginWeb(false, waitForWaConnectionMock as never, runtime); + const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime); + await flushTasks(); + + expect(createWaSocketMock).toHaveBeenCalledTimes(1); + expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce(); + expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(authDir); + + releaseCredsFlush?.(); + await pendingLogin; expect(createWaSocketMock).toHaveBeenCalledTimes(2); const firstSock = await createWaSocketMock.mock.results[0]?.value; diff --git a/extensions/whatsapp/src/login.ts b/extensions/whatsapp/src/login.ts index 3eae0732c5d..0923a38a122 100644 --- a/extensions/whatsapp/src/login.ts +++ b/extensions/whatsapp/src/login.ts @@ -5,7 +5,14 @@ import { danger, info, success } from "../../../src/globals.js"; import { logInfo } from "../../../src/logger.js"; import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js"; import { resolveWhatsAppAccount } from "./accounts.js"; -import { createWaSocket, formatError, logoutWeb, waitForWaConnection } from "./session.js"; +import { + createWaSocket, + formatError, + getStatusCode, + logoutWeb, + waitForCredsSaveQueueWithTimeout, + waitForWaConnection, +} from "./session.js"; export async function loginWeb( verbose: boolean, @@ -24,20 +31,17 @@ export async function loginWeb( await wait(sock); console.log(success("✅ Linked! Credentials saved for future sends.")); } catch (err) { - const code = - (err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode ?? - (err as { output?: { statusCode?: number } })?.output?.statusCode; + const code = getStatusCode(err); if (code === 515) { console.log( - info( - "WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…", - ), + info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"), ); try { sock.ws?.close(); } catch { // ignore } + await waitForCredsSaveQueueWithTimeout(account.authDir); const retry = await createWaSocket(false, verbose, { authDir: account.authDir, }); diff --git a/extensions/whatsapp/src/session.test.ts b/extensions/whatsapp/src/session.test.ts index 177c8c8e5e6..d86de75ffa7 100644 --- a/extensions/whatsapp/src/session.test.ts +++ b/extensions/whatsapp/src/session.test.ts @@ -204,6 +204,62 @@ describe("web session", () => { expect(inFlight).toBe(0); }); + it("lets different authDir queues flush independently", async () => { + let inFlightA = 0; + let inFlightB = 0; + let releaseA: (() => void) | null = null; + let releaseB: (() => void) | null = null; + const gateA = new Promise((resolve) => { + releaseA = resolve; + }); + const gateB = new Promise((resolve) => { + releaseB = resolve; + }); + + const saveCredsA = vi.fn(async () => { + inFlightA += 1; + await gateA; + inFlightA -= 1; + }); + const saveCredsB = vi.fn(async () => { + inFlightB += 1; + await gateB; + inFlightB -= 1; + }); + useMultiFileAuthStateMock + .mockResolvedValueOnce({ + state: { creds: {} as never, keys: {} as never }, + saveCreds: saveCredsA, + }) + .mockResolvedValueOnce({ + state: { creds: {} as never, keys: {} as never }, + saveCreds: saveCredsB, + }); + + await createWaSocket(false, false, { authDir: "/tmp/wa-a" }); + const sockA = getLastSocket(); + await createWaSocket(false, false, { authDir: "/tmp/wa-b" }); + const sockB = getLastSocket(); + + sockA.ev.emit("creds.update", {}); + sockB.ev.emit("creds.update", {}); + + await flushCredsUpdate(); + + expect(saveCredsA).toHaveBeenCalledTimes(1); + expect(saveCredsB).toHaveBeenCalledTimes(1); + expect(inFlightA).toBe(1); + expect(inFlightB).toBe(1); + + (releaseA as (() => void) | null)?.(); + (releaseB as (() => void) | null)?.(); + await flushCredsUpdate(); + await flushCredsUpdate(); + + expect(inFlightA).toBe(0); + expect(inFlightB).toBe(0); + }); + it("rotates creds backup when creds.json is valid JSON", async () => { const creds = mockCredsJsonSpies("{}"); const backupSuffix = path.join( diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index db48b49c874..8fc7f9fd1fc 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -31,17 +31,24 @@ export { webAuthExists, } from "./auth-store.js"; -let credsSaveQueue: Promise = Promise.resolve(); +// Per-authDir queues so multi-account creds saves don't block each other. +const credsSaveQueues = new Map>(); +const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000; function enqueueSaveCreds( authDir: string, saveCreds: () => Promise | void, logger: ReturnType, ): void { - credsSaveQueue = credsSaveQueue + const prev = credsSaveQueues.get(authDir) ?? Promise.resolve(); + const next = prev .then(() => safeSaveCreds(authDir, saveCreds, logger)) .catch((err) => { logger.warn({ error: String(err) }, "WhatsApp creds save queue error"); + }) + .finally(() => { + if (credsSaveQueues.get(authDir) === next) credsSaveQueues.delete(authDir); }); + credsSaveQueues.set(authDir, next); } async function safeSaveCreds( @@ -186,10 +193,37 @@ export async function waitForWaConnection(sock: ReturnType) export function getStatusCode(err: unknown) { return ( (err as { output?: { statusCode?: number } })?.output?.statusCode ?? - (err as { status?: number })?.status + (err as { status?: number })?.status ?? + (err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode ); } +/** Await pending credential saves — scoped to one authDir, or all if omitted. */ +export function waitForCredsSaveQueue(authDir?: string): Promise { + if (authDir) { + return credsSaveQueues.get(authDir) ?? Promise.resolve(); + } + return Promise.all(credsSaveQueues.values()).then(() => {}); +} + +/** Await pending credential saves, but don't hang forever on stalled I/O. */ +export async function waitForCredsSaveQueueWithTimeout( + authDir: string, + timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS, +): Promise { + let flushTimeout: ReturnType | undefined; + await Promise.race([ + waitForCredsSaveQueue(authDir), + new Promise((resolve) => { + flushTimeout = setTimeout(resolve, timeoutMs); + }), + ]).finally(() => { + if (flushTimeout) { + clearTimeout(flushTimeout); + } + }); +} + function safeStringify(value: unknown, limit = 800): string { try { const seen = new WeakSet();