Skip to content

deps: bump whatsmeow to fix stream-error reconnect loop that stalls offline queue delivery - #190

Open
LineckerN wants to merge 2 commits into
evolution-foundation:mainfrom
LineckerN:bump-whatsmeow-stream-error
Open

deps: bump whatsmeow to fix stream-error reconnect loop that stalls offline queue delivery#190
LineckerN wants to merge 2 commits into
evolution-foundation:mainfrom
LineckerN:bump-whatsmeow-stream-error

Conversation

@LineckerN

@LineckerN LineckerN commented Sep 8, 2026

Copy link
Copy Markdown

Problem

On 0.7.2 a linked device flaps every ~50 minutes and stops draining its offline
queue. The server ends the stream with a node whatsmeow doesn't recognise:

[Client WARN]  Received stream end frame
[Client ERROR] Unknown stream error: <stream:error><ack class="status" id="3EB0..." type="media"/></stream:error>
[Client/Socket ERROR] Error reading from websocket: failed to get reader: failed to read frame header: EOF
→ Disconnected detected, restarting instance

Over 30 h on a single instance: 34 stream errors, 38 re-authentications. The
id rotates every few hours (5 distinct ids observed) and never corresponds to a
real message — it is not in the instance's message history.

The damaging part is not the flap itself but what it does to delivery.
OfflineSyncPreview is announced on every reconnect and keeps growing, while
almost nothing is delivered:

time (UTC) Total Messages
13:30 3821 738
16:00 4365 947
16:04 partial drain (224 messages finally delivered)
17:24 3945 765
19:54 4600 923

Net effect: a ~19 h window with zero messages captured, then one burst of
backlog, then silence again. Same error signature as #185 (that report ends in a
terminal dead client; here the client keeps reconnecting but never delivers).

Cause

go.mod pins go.mau.fi/whatsmeow v0.0.0-20260630180629-b572e5bcb92b. Three
upstream fixes landed after that pin and map directly onto the symptoms:

commit date subject
28bfe53 2026-09-04 client: ensure stream error is handled before reconnecting
0fadda7 2026-08-28 client,socket: use correct context for noise socket frames
4fa3462 2026-08-21 client: don't reuse handler queue between connections

4fa3462 in particular explains the queue being announced but never processed:
the handler queue was being reused across connections.

Change

  • go.mau.fi/whatsmeowv0.0.0-20260904121843-28bfe537ea6a
  • whatsmeow ≥ 20260904 requires Go 1.26, so the go directive and the builder
    image in Dockerfile move to 1.26 (golang:1.26-alpine).
  • One API adaptation: SetStatusMessage now takes a structured
    types.SetStatusInput instead of a bare string.
-err = client.SetStatusMessage(context.Background(), data.Status)
+err = client.SetStatusMessage(context.Background(), types.SetStatusInput{Text: &data.Status})

Transitive bumps in go.sum come from go mod tidy.

Testing

Built and deployed to a production single-node Swarm on 2026-09-08 12:57 UTC,
against the same paired instance that produced the report above.

  • Builds clean with the updated toolchain (CGO_ENABLED=1, alpine builder).
  • Binary confirmed to carry the new dependency:
    go.mau.fi/whatsmeow v0.0.0-20260904121843-28bfe537ea6a.

Same instance, same host, before and after:

0.7.2 (~3 days) this branch (~67 min)
Unknown stream error 46 0
Disconnected 300 0

On reconnect the client reported an offline backlog of 14144 events
(3301 messages) accumulated over ~1.5 days of the loop. It drained completely
in 59 minutes
— 3056 messages processed, rate falling from ~120/2min to 0 —
and stayed at live traffic afterwards. Under 0.7.2 the same backlog only grew.

A deliberate service restart mid-test (to apply an unrelated config change) was
also clean: the instance reconnected on its own and resumed processing 10 s after
boot, with no stream error.

Caveat on scope: this is ~1 hour of production uptime. That is past the ~50 min
interval at which the loop reliably reproduced under 0.7.2, but it is not a
multi-day soak. Reporting it as strong evidence, not proof.

Secondary failure mode worth noting

The reconnect loop leaked one PostgreSQL connection per cycle. After ~99 cycles
the instance had exhausted max_connections (100) with idle connections owned by
containers that no longer existed, and the client could no longer start at
all:

Failed to create container: failed to upgrade database:
failed to check if version table is up to date: pq: sorry, too many clients already

At that point the gateway had zero usable connections and capture stopped
entirely — which is how a flapping-but-alive client turns into the fully dead
client described in #185. Clearing the orphans was required before the new build
could start; afterwards connection count has stayed flat at 2-4 with no growth,
since there are no reconnect cycles to leak from.

I did not investigate where the connections are held open, so this part is a
report rather than a fix — it may be worth a separate look regardless of the
whatsmeow bump.

🤖 Generated with Claude Code

https://claude.ai/code/session_014UPPP6Bg3NnBcLfJFEGVw1

Summary by Sourcery

Upgrade the WhatsApp client dependency and database store lifecycle to keep reconnecting clients healthy and reliably process offline queues.

Bug Fixes:

  • Prevent PostgreSQL and SQLite store connection pools from being recreated on reconnect, avoiding leaked connections and eventual database exhaustion.
  • Update the whatsmeow integration to address stream-error reconnect loops and ensure offline message queues continue draining.
  • Adapt profile status updates to the structured status input required by the upgraded whatsmeow API.

Enhancements:

  • Upgrade whatsmeow and related dependencies to versions containing connection, stream handling, and handler queue fixes.

Build:

  • Require Go 1.26 and update the Docker builder image accordingly.

…ect loop

The pinned whatsmeow (20260630-b572e5bc) predates three upstream fixes that
together cause a linked device to flap and stop draining its offline queue:

  28bfe53 (2026-09-04) client: ensure stream error is handled before reconnecting
  0fadda7 (2026-08-28) client,socket: use correct context for noise socket frames
  4fa3462 (2026-08-21) client: don't reuse handler queue between connections

Observed on 0.7.2 in production: every ~50 min the server ends the stream with

  <stream:error><ack class="status" id="3EB0..." type="media"/></stream:error>

followed by a websocket EOF and a reconnect. Over 30 h that produced 34 stream
errors and 38 re-authentications on a single instance. The client keeps
authenticating, but OfflineSyncPreview only grows (Messages 738 -> 923) and no
message is ever delivered, so live capture stops entirely. Same signature as evolution-foundation#185.

whatsmeow >= 20260904 requires Go 1.26, so the go directive and the builder
image move with it. The only source change needed is SetStatusMessage, which now
takes a structured types.SetStatusInput instead of a bare string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013rUP8Un1mWQdfRg1cMSc73
@sourcery-ai

sourcery-ai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

This PR upgrades whatsmeow and the Go toolchain to pull in upstream fixes for stream-error handling, socket context usage, and per-connection handler queues, adapts the status-message API, and reports production evidence that reconnect loops and offline-queue stalls stopped; reviewers should also assess the broad transitive dependency refresh and the unresolved PostgreSQL connection-leak failure mode.

Sequence diagram for reconnect and offline queue recovery

sequenceDiagram
    participant Whatsmeow
    participant Client
    participant HandlerQueue
    participant OfflineQueue

    Whatsmeow->>Client: stream error
    Client->>Client: handle stream error
    Client->>HandlerQueue: create queue for connection
    Client->>Whatsmeow: reconnect
    Whatsmeow-->>Client: OfflineSyncPreview
    Client->>OfflineQueue: process queued events
    OfflineQueue-->>Client: backlog drained
    Client-->>Whatsmeow: continue live traffic
Loading

Sequence diagram for structured profile status update

sequenceDiagram
    participant UserService
    participant WhatsmeowClient
    participant Whatsmeow

    UserService->>WhatsmeowClient: SetStatusMessage(context, SetStatusInput)
    WhatsmeowClient->>Whatsmeow: update status text
    Whatsmeow-->>WhatsmeowClient: result
    WhatsmeowClient-->>UserService: result
Loading

File-Level Changes

Change Details Files
Upgrade whatsmeow and the Go toolchain to incorporate upstream stream-error, socket-context, and connection-handler queue fixes.
  • Bump whatsmeow to the 2026-09-04 revision and refresh related direct and transitive modules via go mod tidy.
  • Move the module and Alpine builder image from Go 1.25 to Go 1.26.
  • Review the dependency lockfile changes for unintended transitive upgrades and confirm the production binary resolves the intended whatsmeow revision.
go.mod
go.sum
Dockerfile
Adapt profile status updates to whatsmeow’s structured status-message API.
  • Wrap the existing status text in types.SetStatusInput while preserving the prior text-only behavior.
  • Verify the status update path still handles API errors and does not unintentionally alter emoji or duration semantics.
pkg/user/service/user_service.go
Validate that the dependency upgrade resolves reconnect-driven offline queue stalls and does not reproduce the observed stream-error loop.
  • Confirm builds succeed with CGO enabled under the Go 1.26 Alpine builder.
  • Check reconnect and offline-sync behavior, including complete backlog drainage and recovery after a deliberate service restart.
  • Monitor longer-term stream errors, disconnects, queue delivery, and PostgreSQL connection counts; the reported production validation is approximately one hour and does not address the separately reported connection leak.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've reviewed your changes and they look great!

Fixed security issues:

  • golang.org/x/crypto (link)
  • golang.org/x/text (link)

Sourcery assessment

Needs a human reviewer. The whatsmeow upgrade changes the runtime behavior of reconnecting and delivering queued outbound messages, so a regression could cause messages to be duplicated, misdelivered, or sent at the wrong time to external recipients. Reverting restores the previous client behavior, but any messages already sent cannot be recalled.


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

…onnect

`StartClient` called `sqlstore.New` on every (re)connection. Each call opens a
fresh `*sql.DB`, and Go's pool keeps its idle connections alive indefinitely.
The container was a local variable — never stored, never closed — so every
reconnect leaked a pool.

Measured on a production instance: the reconnect loop pushed `pg_stat_activity`
from 2 to 99 idle connections owned by containers that no longer existed. Once
`max_connections` (100) was exhausted the client could no longer even start:

    Failed to create container: failed to upgrade database:
    failed to check if version table is up to date: pq: sorry, too many clients already

At that point capture stops entirely — a flapping-but-alive client becomes a
dead one. Keepalive timeouts alone (normal on a long-lived socket) are enough to
reach this state given enough days.

The DSN is fixed for the process lifetime, and `sqlstore.Container` is safe for
concurrent use, so one shared container per DSN is both sufficient and correct.
A small mutex-guarded map keeps it lazy and keyed by dialect+address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXKS6ixSuQsCLfaF41oHKB
@LineckerN

Copy link
Copy Markdown
Author

Pushed a second commit to this branch: df7b7e3.

The "secondary failure mode" I described under Testing turned out to be a bug in
this repo, not in whatsmeow, and I was able to pin it down exactly.

StartClient calls sqlstore.New on every (re)connection:

container, err = sqlstore.New(context.Background(), "postgres", w.config.PostgresAuthDB, nil)

container is a local variable — never stored, never closed. Each call opens a
fresh *sql.DB, and Go's pool keeps its idle connections alive indefinitely, so
every reconnect leaks a pool.

That is why the counts I reported climbed to 99 idle connections owned by
containers that no longer existed, and why the client eventually could not start
at all with pq: sorry, too many clients already.

Worth stressing: this does not need the stream-error bug to trigger. After
deploying the whatsmeow bump the stream errors went to zero, but ordinary
KeepAliveTimeout events (normal on a long-lived socket — 6 in 18 h here) still
call the same reconnect path. The pool went 2 → 14 overnight. Slower, but the
same destination.

The fix keeps one shared container per DSN behind a mutex-guarded map. The DSN is
fixed for the process lifetime and sqlstore.Container is safe for concurrent
use, so a single instance is both sufficient and correct.

Deployed here: the instance reconnects normally and the pool sits at a flat 3
connections instead of growing.

Happy to split this into its own PR if you would rather keep the dependency bump
isolated — it is an independent fix and stands on its own.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant