fix: force web reconnect on stalled close
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user