fix: force web reconnect on stalled close

This commit is contained in:
Peter Steinberger
2025-12-28 12:04:20 +00:00
parent cade7b1132
commit eb158545fc
4 changed files with 100 additions and 2 deletions

View File

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

View File

@@ -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<void>)
| undefined;
const listenerFactory = vi.fn(async (opts: {
onMessage: (
msg: import("./inbound.js").WebInboundMessage,
) => Promise<void>;
}) => {
capturedOnMessage = opts.onMessage;
let resolveClose: (reason: unknown) => void = () => {};
const onClose = new Promise<unknown>((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 },

View File

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

View File

@@ -78,6 +78,12 @@ export async function monitorWebInbox(options: {
const onClose = new Promise<WebListenerCloseReason>((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.