Skip to content

fix: stop an abandoned QR session from evicting the live one - #347

Open
luyzphellype wants to merge 1 commit into
asternic:mainfrom
luyzphellype:fix/stale-qr-session-eviction
Open

fix: stop an abandoned QR session from evicting the live one#347
luyzphellype wants to merge 1 commit into
asternic:mainfrom
luyzphellype:fix/stale-qr-session-eviction

Conversation

@luyzphellype

Copy link
Copy Markdown

The problem

Two concurrent POST /session/connect for the same user both pass the already connected guard when nothing is connected yet. Two startClient goroutines then run for that user, each with its own device and its own QR channel. The user scans one of them.

Minutes later the other QR expires unscanned, and its goroutine cleans up — addressing the shared state by bare userID. So it evicts the session that had just paired.

The symptom is nasty to diagnose:

  1. Pairing succeeds, messages flow.
  2. ~3 minutes later the session goes silent, with no error and no webhook (the teardown calls client.Disconnect(), which whatsmeow treats as an expected disconnect and therefore does not report as events.Disconnected).
  3. /session/status now reports loggedIn: false with an empty jid, while the paired device sits intact in whatsmeow_device — so it looks like the credentials were lost.
  4. /session/connect reads the empty JID from the cache, builds a fresh device and returns a new QR. Re-scanning creates yet another device and can revoke the previous one (401: logged out from another device).

Only a restart recovers it, because the JID is still correct in the database and only the in-memory cache was clobbered.

What causes it

Two independent defects, both reachable whenever an abandoned QR goroutine outlives the session that won the race.

1. Cleanup addressed by userID instead of by session. The QR-timeout branch and the kill path both call DeleteWhatsmeowClient(userID) / DeleteMyClient(userID) / DeleteHTTPClient(userID), plus signalKill(userID). All of these resolve to whatever session is registered now. deleteKillChannel already guards against exactly this — it deletes only if the entry still maps to the caller's channel — but the clientManager entries and signalKill had no equivalent.

2. The QR loop wrote the user-info cache from a stale snapshot. It captured myuserinfo once, before pairing (when Jid is empty), and rewrote the cache from that snapshot on every new QR code. updateUserInfo is copy-on-write over every field, so a code emitted after PairSuccess restored the empty Jid. That is what makes the next connect build a fresh device.

The fix

Each change narrows "act on this user" to "act on my own session".

  • clients.goDeleteSessionIfCurrent(userID, client): the clientManager counterpart of deleteKillChannel's staleness guard. Drops the three per-user maps under a single lock, and only when the registered whatsmeow client is still the caller's.
  • main.gosignalKillChannel(ch): kills one specific channel. A goroutine ending itself passes the channel it captured, instead of resolving userID to a session that may no longer be its own.
  • helpers.gosetUserInfoField(token, field, value): re-reads the cache entry immediately before writing it back, so a long-lived goroutine can no longer revert fields changed in the meantime.
  • wmiau.go — the QR-timeout branch now only signals its own kill channel and lets the single cleanup at the end of startClient run, instead of duplicating the teardown. That cleanup skips the connected=0 write when it is not the current session, so a stale goroutine cannot mark a live session disconnected.

No API or behaviour change for the single-session path.

Tests

stale_session_test.go covers each primitive: the staleness guard (stale, own, unknown, and concurrent), the kill targeting its own channel, and the cache read-modify-write. TestStaleSnapshotRevertsFields asserts the contrast directly — the snapshot write reverts the JID, the re-read does not — so the reason the helper exists is pinned down rather than implied.

go vet ./... and go test -race ./... pass.

Verified on a live instance as well: two concurrent connects for one user produce two QR channels, and when both expire, exactly one goroutine performs the teardown while the other logs Stale session goroutine exiting; a newer session owns this user and stands down. A separate paired session stayed up throughout.

Two concurrent POST /session/connect for the same user both pass the
"already connected" guard when nothing is connected yet, so two
startClient goroutines run, each with its own device and its own QR
channel. The user scans one. Minutes later the other QR expires
unscanned and its goroutine cleans up — addressing the shared state by
bare userID, so it tears down the session that had just paired.

The symptom is brutal to diagnose: pairing succeeds, messages flow for a
few minutes, then the session goes silent. /session/status reports
loggedIn=false with an empty JID while the paired device sits intact in
whatsmeow_device, so it looks like the credentials were lost. Only a
restart brings it back.

Three fixes, all narrowing "act on this user" to "act on my own session":

- clients.go: add DeleteSessionIfCurrent, the clientManager counterpart
  of deleteKillChannel's existing staleness guard. It drops the three
  per-user maps under one lock, and only if the registered whatsmeow
  client is still the caller's.

- main.go: add signalKillChannel, which kills one specific channel. A
  goroutine ending itself must not go through signalKill(userID), which
  resolves to whatever session is registered now — after a racing
  connect, that is a different, live session.

- helpers.go: add setUserInfoField, which re-reads the cache entry
  immediately before writing it back. The QR loop captured the user info
  once, before pairing (when Jid is empty), and rewrote the cache from
  that snapshot on every new QR code — so a code emitted after
  PairSuccess restored the empty Jid. That is why the next connect built
  a fresh device and asked for a new QR.

The QR timeout branch now only signals its own kill channel and lets the
single cleanup at the end of startClient run, instead of duplicating the
teardown.

Tests in stale_session_test.go cover each primitive, including the
contrast between the stale-snapshot write and the re-read, so the reason
the helper exists is pinned down. go vet and go test -race pass.
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

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