From dfcf6401a17c444db52f7eff5d39929edff82cee Mon Sep 17 00:00:00 2001 From: Flavio Pulli Date: Fri, 24 Jul 2026 11:05:37 -0300 Subject: [PATCH] fix(whatsmeow): recover zombie connections on KeepAliveTimeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With EnableAutoReconnect disabled, a TCP connection that dies silently (no FIN/RST from the server) never emits events.Disconnected: keepalive pings just keep timing out and the instance stays flagged as connected while being unable to send or receive — a zombie that only a manual restart fixes. whatsmeow's docs explicitly suggest using KeepAliveTimeout to force a faster disconnect+reconnect. Handle KeepAliveTimeout and KeepAliveRestored: both are forwarded to the CONNECTION webhook subscription so operators can observe socket health, and after 3 consecutive timeouts the instance is restarted through the same non-blocking ReconnectClient path the Disconnected handler already uses (exact-match on the count so ongoing restarts aren't duplicated). --- pkg/whatsmeow/service/whatsmeow.go | 35 +++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 366f0edb..11d64bc7 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -1988,6 +1988,35 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { mycli.loggerWrapper.GetLogger(instanceID).LogError("[%s] Failed to restart instance: %v", instanceID, err) } }(mycli.userID) + case *events.KeepAliveTimeout: + doWebhook = true + postMap["event"] = "KeepAliveTimeout" + postMap["data"] = map[string]interface{}{ + "ErrorCount": evt.ErrorCount, + "LastSuccess": evt.LastSuccess, + } + mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn("[%s] Keepalive ping timed out (%d consecutive, last success: %s)", mycli.userID, evt.ErrorCount, evt.LastSuccess.Format(time.RFC3339)) + + // With EnableAutoReconnect disabled, a TCP connection that silently dies + // keeps timing out keepalives forever without ever emitting + // events.Disconnected — the instance stays "connected" but is a zombie. + // whatsmeow's own docs suggest using this event to force a faster + // disconnect+reconnect, so after 3 consecutive timeouts restart the + // instance through the same path the Disconnected handler uses. Exact + // match keeps counts 4, 5, ... from piling up restarts while one is + // already underway. + if evt.ErrorCount == 3 { + go func(instanceID string) { + mycli.loggerWrapper.GetLogger(instanceID).LogWarn("[%s] 3 consecutive keepalive timeouts, restarting instance", instanceID) + if err := mycli.service.ReconnectClient(instanceID); err != nil { + mycli.loggerWrapper.GetLogger(instanceID).LogError("[%s] Failed to restart instance: %v", instanceID, err) + } + }(mycli.userID) + } + case *events.KeepAliveRestored: + doWebhook = true + postMap["event"] = "KeepAliveRestored" + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Keepalive pings restored", mycli.userID) case *events.LabelEdit: doWebhook = true postMap["event"] = "LabelEdit" @@ -2222,7 +2251,7 @@ func (w *whatsmeowService) CallWebhook(instance *instance_model.Instance, queueN w.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Event received of type %s", instance.Id, eventType) w.sendToQueueOrWebhook(instance, queueName, jsonData) } - case "Connected", "PairSuccess", "TemporaryBan", "LoggedOut", "ConnectFailure", "Disconnected": + case "Connected", "PairSuccess", "TemporaryBan", "LoggedOut", "ConnectFailure", "Disconnected", "KeepAliveTimeout", "KeepAliveRestored": if contains(subscriptions, "CONNECTION") { w.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Event received of type %s", instance.Id, eventType) w.sendToQueueOrWebhook(instance, queueName, jsonData) @@ -2510,7 +2539,7 @@ func (w *whatsmeowService) SendToGlobalQueues(eventType string, payload []byte, globalEventType = "CHAT_PRESENCE" case "CallOffer", "CallAccept", "CallTerminate", "CallOfferNotice", "CallRelayLatency": globalEventType = "CALL" - case "Connected", "PairSuccess", "TemporaryBan", "LoggedOut", "ConnectFailure", "Disconnected": + case "Connected", "PairSuccess", "TemporaryBan", "LoggedOut", "ConnectFailure", "Disconnected", "KeepAliveTimeout", "KeepAliveRestored": globalEventType = "CONNECTION" case "LabelEdit", "LabelAssociationChat", "LabelAssociationMessage": globalEventType = "LABEL" @@ -2572,7 +2601,7 @@ func (w *whatsmeowService) SendToGlobalQueues(eventType string, payload []byte, globalEventType = "CHAT_PRESENCE" case "CallOffer", "CallAccept", "CallTerminate", "CallOfferNotice", "CallRelayLatency": globalEventType = "CALL" - case "Connected", "PairSuccess", "TemporaryBan", "LoggedOut", "ConnectFailure", "Disconnected": + case "Connected", "PairSuccess", "TemporaryBan", "LoggedOut", "ConnectFailure", "Disconnected", "KeepAliveTimeout", "KeepAliveRestored": globalEventType = "CONNECTION" case "LabelEdit", "LabelAssociationChat", "LabelAssociationMessage": globalEventType = "LABEL"