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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user