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. - 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 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. - 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 ## 2.0.0-beta3 — 2025-12-27

View File

@@ -311,6 +311,87 @@ describe("web auto-reply", () => {
await run; 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( it(
"stops after hitting max reconnect attempts", "stops after hitting max reconnect attempts",
{ timeout: 20000 }, { timeout: 20000 },

View File

@@ -1361,6 +1361,11 @@ export async function monitorWebProvider(
void closeListener().catch((err) => { void closeListener().catch((err) => {
logVerbose(`Close listener failed: ${formatError(err)}`); logVerbose(`Close listener failed: ${formatError(err)}`);
}); // Trigger reconnect }); // Trigger reconnect
listener.signalClose?.({
status: 499,
isLoggedOut: false,
error: "watchdog-timeout",
});
} }
} }
}, WATCHDOG_CHECK_MS); }, WATCHDOG_CHECK_MS);

View File

@@ -78,6 +78,12 @@ export async function monitorWebInbox(options: {
const onClose = new Promise<WebListenerCloseReason>((resolve) => { const onClose = new Promise<WebListenerCloseReason>((resolve) => {
onCloseResolve = resolve; onCloseResolve = resolve;
}); });
const resolveClose = (reason: WebListenerCloseReason) => {
if (!onCloseResolve) return;
const resolver = onCloseResolve;
onCloseResolve = null;
resolver(reason);
};
try { try {
// Advertise that the gateway is online right after connecting. // Advertise that the gateway is online right after connecting.
await sock.sendPresenceUpdate("available"); await sock.sendPresenceUpdate("available");
@@ -310,7 +316,7 @@ export async function monitorWebInbox(options: {
try { try {
if (update.connection === "close") { if (update.connection === "close") {
const status = getStatusCode(update.lastDisconnect?.error); const status = getStatusCode(update.lastDisconnect?.error);
onCloseResolve?.({ resolveClose({
status, status,
isLoggedOut: status === DisconnectReason.loggedOut, isLoggedOut: status === DisconnectReason.loggedOut,
error: update.lastDisconnect?.error, error: update.lastDisconnect?.error,
@@ -321,7 +327,7 @@ export async function monitorWebInbox(options: {
{ error: String(err) }, { error: String(err) },
"connection.update handler error", "connection.update handler error",
); );
onCloseResolve?.({ resolveClose({
status: undefined, status: undefined,
isLoggedOut: false, isLoggedOut: false,
error: err, error: err,
@@ -359,6 +365,11 @@ export async function monitorWebInbox(options: {
} }
}, },
onClose, onClose,
signalClose: (reason?: WebListenerCloseReason) => {
resolveClose(
reason ?? { status: undefined, isLoggedOut: false, error: "closed" },
);
},
/** /**
* Send a message through this connection's socket. * Send a message through this connection's socket.
* Used by IPC to avoid creating new connections. * Used by IPC to avoid creating new connections.