From 4cc635dd460f70c36ba83285ecbb34589790572f Mon Sep 17 00:00:00 2001 From: Ay0rus <160690300+Ay0rus@users.noreply.github.com> Date: Sat, 4 Jul 2026 00:13:12 -0300 Subject: [PATCH 1/3] fix(whatsmeow): reuse a single capped sqlstore container instead of one per StartClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StartClient() called sqlstore.New() on every connect AND every reconnect, each opening a brand-new *sql.DB with MaxOpenConns=0 (unlimited) and ConnMaxLifetime=0 (never expires), and the container was never closed. With instances reconnecting in a loop (QR expiry, network blips, CONNECT_ON_STARTUP) this leaks one connection pool per attempt and eventually exhausts Postgres ("sorry, too many clients already"). Create the auth-store container once (sync.Once) with a bounded pool via sqlstore.NewWithDB(), and reuse it across all clients — whatsmeow's container is designed to hold multiple devices. Direct DB connection, capped pool. --- pkg/whatsmeow/service/whatsmeow.go | 68 +++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 78ec6c17..46915812 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -279,6 +279,53 @@ func (w whatsmeowService) ForceUpdateJid(instanceId string, number string) error return nil } +// --- PATCH leak-fix (#269): container/pool UNICO e capado pro store do whatsmeow --- +// Antes, cada StartClient (conectar E cada reconexao) chamava sqlstore.New(), abrindo +// um *sql.DB novo sem cap e nunca fechado -> leak que saturava o Postgres do evogo_auth. +// Agora e um container compartilhado, criado uma vez, com pool limitado e conexao direta. +var ( + sharedAuthContainer *sqlstore.Container + sharedAuthContainerErr error + sharedAuthContainerOnce sync.Once +) + +func (w whatsmeowService) getAuthContainer() (*sqlstore.Container, error) { + sharedAuthContainerOnce.Do(func() { + var dbLog waLog.Logger + if w.config.WaDebug != "" { + dbLog = waLog.Stdout("Database", w.config.WaDebug, true) + } + var dialect, address string + if w.config.PostgresAuthDB != "" { + dialect, address = "postgres", w.config.PostgresAuthDB + } else { + dialect = "sqlite" + address = fmt.Sprintf("file:%s/dbdata/main.db?_pragma=foreign_keys(1)&_busy_timeout=5000&cache=shared&mode=rwc&_journal_mode=WAL", w.exPath) + } + db, err := sql.Open(dialect, address) + if err != nil { + sharedAuthContainerErr = fmt.Errorf("failed to open auth database: %w", err) + return + } + if dialect == "postgres" { + db.SetMaxOpenConns(20) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + db.SetConnMaxIdleTime(2 * time.Minute) + } else { + db.SetMaxOpenConns(1) + } + container := sqlstore.NewWithDB(db, dialect, dbLog) + if err := container.Upgrade(context.Background()); err != nil { + _ = db.Close() + sharedAuthContainerErr = fmt.Errorf("failed to upgrade auth database: %w", err) + return + } + sharedAuthContainer = container + }) + return sharedAuthContainer, sharedAuthContainerErr +} + func (w whatsmeowService) StartClient(cd *ClientData) { w.loggerWrapper.GetLogger(cd.Instance.Id).LogInfo("Starting websocket connection to Whatsapp for user '%s'", cd.Instance.Id) @@ -293,26 +340,9 @@ func (w whatsmeowService) StartClient(cd *ClientData) { } var container *sqlstore.Container - - if w.config.WaDebug != "" { - dbLog := waLog.Stdout("Database", w.config.WaDebug, true) - if w.config.PostgresAuthDB != "" { - container, err = sqlstore.New(context.Background(), "postgres", w.config.PostgresAuthDB, dbLog) - } else { - dsn := fmt.Sprintf("file:%s/dbdata/main.db?_pragma=foreign_keys(1)&_busy_timeout=5000&cache=shared&mode=rwc&_journal_mode=WAL", w.exPath) - container, err = sqlstore.New(context.Background(), "sqlite", dsn, dbLog) - } - } else { - if w.config.PostgresAuthDB != "" { - container, err = sqlstore.New(context.Background(), "postgres", w.config.PostgresAuthDB, nil) - } else { - dsn := fmt.Sprintf("file:%s/dbdata/main.db?_pragma=foreign_keys(1)&_busy_timeout=5000&cache=shared&mode=rwc&_journal_mode=WAL", w.exPath) - container, err = sqlstore.New(context.Background(), "sqlite", dsn, nil) - } - } - + container, err = w.getAuthContainer() if err != nil { - w.loggerWrapper.GetLogger(cd.Instance.Id).LogError("[%s] Failed to create container: %v", cd.Instance.Id, err) + w.loggerWrapper.GetLogger(cd.Instance.Id).LogError("[%s] Failed to get auth container: %v", cd.Instance.Id, err) return } From f85ea1445373ea142bb12ba0c01dfc85879be212 Mon Sep 17 00:00:00 2001 From: Guilherme Cassettari Date: Wed, 15 Jul 2026 10:08:14 -0300 Subject: [PATCH 2/3] fix(whatsmeow): retry auth container creation on failure instead of caching the error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync.Once memorized the first failure forever: a Postgres hiccup at the first StartClient left every future connect returning the same stale error until process restart. Replace it with a mutex that only memoizes success — failures retry on the next call. Adds a self-contained regression test using the sqlite path (no external services required). --- .../service/auth_container_retry_test.go | 57 +++++++++++++ pkg/whatsmeow/service/whatsmeow.go | 82 ++++++++++--------- 2 files changed, 100 insertions(+), 39 deletions(-) create mode 100644 pkg/whatsmeow/service/auth_container_retry_test.go diff --git a/pkg/whatsmeow/service/auth_container_retry_test.go b/pkg/whatsmeow/service/auth_container_retry_test.go new file mode 100644 index 00000000..762c575e --- /dev/null +++ b/pkg/whatsmeow/service/auth_container_retry_test.go @@ -0,0 +1,57 @@ +package whatsmeow_service + +import ( + "os" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" + + "github.com/EvolutionAPI/evolution-go/pkg/config" +) + +// Valida a semântica do getAuthContainer compartilhado, usando o caminho SQLite +// (auto-contido, sem serviço externo — roda em qualquer CI): +// 1. falha na criação NÃO é memorizada (retry possível em vez de erro cacheado pra sempre) +// 2. sucesso é memorizado e o MESMO container é reusado nas chamadas seguintes +func TestGetAuthContainerRetryAndReuse(t *testing.T) { + // estado limpo do singleton + sharedAuthContainerMu.Lock() + sharedAuthContainer = nil + sharedAuthContainerMu.Unlock() + + // Falha: exPath aponta pra diretório inexistente — o SQLite não cria + // diretórios intermediários, então o Upgrade falha na primeira conexão. + bad := whatsmeowService{ + config: &config.Config{}, + exPath: filepath.Join(t.TempDir(), "does-not-exist"), + } + if _, err := bad.getAuthContainer(); err == nil { + t.Fatal("esperava erro com diretório inexistente, veio nil") + } + + // Sucesso: exPath válido com o subdiretório dbdata esperado pelo DSN. + goodPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(goodPath, "dbdata"), 0o755); err != nil { + t.Fatal(err) + } + good := whatsmeowService{ + config: &config.Config{}, + exPath: goodPath, + } + c1, err := good.getAuthContainer() + if err != nil { + t.Fatalf("retry após falha deveria funcionar em vez de devolver erro cacheado: %v", err) + } + if c1 == nil { + t.Fatal("container nil após sucesso") + } + + c2, err := good.getAuthContainer() + if err != nil { + t.Fatalf("segunda chamada falhou: %v", err) + } + if c1 != c2 { + t.Fatal("container não foi reusado — deveria ser singleton compartilhado") + } +} diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 46915812..9ed3309d 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -279,51 +279,55 @@ func (w whatsmeowService) ForceUpdateJid(instanceId string, number string) error return nil } -// --- PATCH leak-fix (#269): container/pool UNICO e capado pro store do whatsmeow --- +// Container/pool unico e capado pro store do whatsmeow. // Antes, cada StartClient (conectar E cada reconexao) chamava sqlstore.New(), abrindo // um *sql.DB novo sem cap e nunca fechado -> leak que saturava o Postgres do evogo_auth. -// Agora e um container compartilhado, criado uma vez, com pool limitado e conexao direta. +// Agora e um container compartilhado, com pool limitado e conexao direta. +// Somente o sucesso e memorizado: se a criacao falhar (ex.: Postgres indisponivel +// no boot), a proxima chamada tenta de novo em vez de devolver o erro pra sempre. var ( - sharedAuthContainer *sqlstore.Container - sharedAuthContainerErr error - sharedAuthContainerOnce sync.Once + sharedAuthContainer *sqlstore.Container + sharedAuthContainerMu sync.Mutex ) func (w whatsmeowService) getAuthContainer() (*sqlstore.Container, error) { - sharedAuthContainerOnce.Do(func() { - var dbLog waLog.Logger - if w.config.WaDebug != "" { - dbLog = waLog.Stdout("Database", w.config.WaDebug, true) - } - var dialect, address string - if w.config.PostgresAuthDB != "" { - dialect, address = "postgres", w.config.PostgresAuthDB - } else { - dialect = "sqlite" - address = fmt.Sprintf("file:%s/dbdata/main.db?_pragma=foreign_keys(1)&_busy_timeout=5000&cache=shared&mode=rwc&_journal_mode=WAL", w.exPath) - } - db, err := sql.Open(dialect, address) - if err != nil { - sharedAuthContainerErr = fmt.Errorf("failed to open auth database: %w", err) - return - } - if dialect == "postgres" { - db.SetMaxOpenConns(20) - db.SetMaxIdleConns(5) - db.SetConnMaxLifetime(5 * time.Minute) - db.SetConnMaxIdleTime(2 * time.Minute) - } else { - db.SetMaxOpenConns(1) - } - container := sqlstore.NewWithDB(db, dialect, dbLog) - if err := container.Upgrade(context.Background()); err != nil { - _ = db.Close() - sharedAuthContainerErr = fmt.Errorf("failed to upgrade auth database: %w", err) - return - } - sharedAuthContainer = container - }) - return sharedAuthContainer, sharedAuthContainerErr + sharedAuthContainerMu.Lock() + defer sharedAuthContainerMu.Unlock() + + if sharedAuthContainer != nil { + return sharedAuthContainer, nil + } + + var dbLog waLog.Logger + if w.config.WaDebug != "" { + dbLog = waLog.Stdout("Database", w.config.WaDebug, true) + } + var dialect, address string + if w.config.PostgresAuthDB != "" { + dialect, address = "postgres", w.config.PostgresAuthDB + } else { + dialect = "sqlite" + address = fmt.Sprintf("file:%s/dbdata/main.db?_pragma=foreign_keys(1)&_busy_timeout=5000&cache=shared&mode=rwc&_journal_mode=WAL", w.exPath) + } + db, err := sql.Open(dialect, address) + if err != nil { + return nil, fmt.Errorf("failed to open auth database: %w", err) + } + if dialect == "postgres" { + db.SetMaxOpenConns(20) + db.SetMaxIdleConns(5) + db.SetConnMaxLifetime(5 * time.Minute) + db.SetConnMaxIdleTime(2 * time.Minute) + } else { + db.SetMaxOpenConns(1) + } + container := sqlstore.NewWithDB(db, dialect, dbLog) + if err := container.Upgrade(context.Background()); err != nil { + _ = db.Close() + return nil, fmt.Errorf("failed to upgrade auth database: %w", err) + } + sharedAuthContainer = container + return sharedAuthContainer, nil } func (w whatsmeowService) StartClient(cd *ClientData) { From 03289559d547911d92ad58837db98faeb0c5fd8e Mon Sep 17 00:00:00 2001 From: Guilherme Gasparotti Cassettari Date: Wed, 15 Jul 2026 10:19:11 -0300 Subject: [PATCH 3/3] Update pkg/whatsmeow/service/auth_container_retry_test.go Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> --- pkg/whatsmeow/service/auth_container_retry_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/whatsmeow/service/auth_container_retry_test.go b/pkg/whatsmeow/service/auth_container_retry_test.go index 762c575e..580a22f8 100644 --- a/pkg/whatsmeow/service/auth_container_retry_test.go +++ b/pkg/whatsmeow/service/auth_container_retry_test.go @@ -29,6 +29,13 @@ func TestGetAuthContainerRetryAndReuse(t *testing.T) { if _, err := bad.getAuthContainer(); err == nil { t.Fatal("esperava erro com diretório inexistente, veio nil") } + // Falha não deve ser memorizada no singleton + sharedAuthContainerMu.Lock() + if sharedAuthContainer != nil { + sharedAuthContainerMu.Unlock() + t.Fatalf("failed container should not be memoized (sharedAuthContainer = %#v)", sharedAuthContainer) + } + sharedAuthContainerMu.Unlock() // Sucesso: exPath válido com o subdiretório dbdata esperado pelo DSN. goodPath := t.TempDir()