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 <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* fix: clear flush timeout handle and assert creds queue in test

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: evict settled credsSaveQueues entries to prevent unbounded growth

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: share WhatsApp 515 creds flush handling (#27910) (thanks @asyncjason)

---------

Co-authored-by: Jason Separovic <jason@wilma.dog>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Jason 2026-03-15 04:30:07 -07:00 committed by GitHub
parent 843e3c1efb
commit 9d3e653ec9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 177 additions and 18 deletions

View File

@ -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

View File

@ -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<void>((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);

View File

@ -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,

View File

@ -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<void>((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;

View File

@ -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,
});

View File

@ -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<void>((resolve) => {
releaseA = resolve;
});
const gateB = new Promise<void>((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(

View File

@ -31,17 +31,24 @@ export {
webAuthExists,
} from "./auth-store.js";
let credsSaveQueue: Promise<void> = Promise.resolve();
// Per-authDir queues so multi-account creds saves don't block each other.
const credsSaveQueues = new Map<string, Promise<void>>();
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
function enqueueSaveCreds(
authDir: string,
saveCreds: () => Promise<void> | void,
logger: ReturnType<typeof getChildLogger>,
): 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<typeof makeWASocket>)
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<void> {
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<void> {
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
waitForCredsSaveQueue(authDir),
new Promise<void>((resolve) => {
flushTimeout = setTimeout(resolve, timeoutMs);
}),
]).finally(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
}
});
}
function safeStringify(value: unknown, limit = 800): string {
try {
const seen = new WeakSet();