fix: stop an abandoned QR session from evicting the live one - #347
Open
luyzphellype wants to merge 1 commit into
Open
fix: stop an abandoned QR session from evicting the live one#347luyzphellype wants to merge 1 commit into
luyzphellype wants to merge 1 commit into
Conversation
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.
Contributor
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Two concurrent
POST /session/connectfor the same user both pass thealready connectedguard when nothing is connected yet. TwostartClientgoroutines 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:
client.Disconnect(), which whatsmeow treats as an expected disconnect and therefore does not report asevents.Disconnected)./session/statusnow reportsloggedIn: falsewith an emptyjid, while the paired device sits intact inwhatsmeow_device— so it looks like the credentials were lost./session/connectreads 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
userIDinstead of by session. The QR-timeout branch and the kill path both callDeleteWhatsmeowClient(userID)/DeleteMyClient(userID)/DeleteHTTPClient(userID), plussignalKill(userID). All of these resolve to whatever session is registered now.deleteKillChannelalready guards against exactly this — it deletes only if the entry still maps to the caller's channel — but theclientManagerentries andsignalKillhad no equivalent.2. The QR loop wrote the user-info cache from a stale snapshot. It captured
myuserinfoonce, before pairing (whenJidis empty), and rewrote the cache from that snapshot on every new QR code.updateUserInfois copy-on-write over every field, so a code emitted afterPairSuccessrestored the emptyJid. That is what makes the nextconnectbuild a fresh device.The fix
Each change narrows "act on this user" to "act on my own session".
clients.go—DeleteSessionIfCurrent(userID, client): theclientManagercounterpart ofdeleteKillChannel'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.go—signalKillChannel(ch): kills one specific channel. A goroutine ending itself passes the channel it captured, instead of resolvinguserIDto a session that may no longer be its own.helpers.go—setUserInfoField(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 ofstartClientrun, instead of duplicating the teardown. That cleanup skips theconnected=0write 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.gocovers each primitive: the staleness guard (stale, own, unknown, and concurrent), the kill targeting its own channel, and the cache read-modify-write.TestStaleSnapshotRevertsFieldsasserts 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 ./...andgo 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 userand stands down. A separate paired session stayed up throughout.