Skip to content

fix: guard shared instance maps with a RWMutex - #167

Open
FlavioPulli wants to merge 1 commit into
evolution-foundation:developfrom
FlavioPulli:fix/guard-instance-maps-rwmutex
Open

fix: guard shared instance maps with a RWMutex#167
FlavioPulli wants to merge 1 commit into
evolution-foundation:developfrom
FlavioPulli:fix/guard-instance-maps-rwmutex

Conversation

@FlavioPulli

@FlavioPulli FlavioPulli commented Aug 6, 2026

Copy link
Copy Markdown

develop counterpart of #127, which targets main. Opening it here because on #128 @iagocotta asked that PRs go to develop; #127 is the only one of my earlier PRs that did not get a replacement, and it is also the one with independent production confirmation from another user.

Problem

killChannel, clientPointer and myClientPointer are created in main.go and shared by reference across all service packages and every MyClient. ReconnectClient deletes from all three without a lock, StartClient writes to them, the Disconnected handler triggers ReconnectClient from a goroutine, and every HTTP handler reads them concurrently.

Go fatals on concurrent map access. These are runtime fatal errors, not panics, so recover() is not an option: every occurrence kills the process and takes all instances down with it.

The codebase already acknowledges the hazard — the comment on teardownQR in pkg/whatsmeow/service/whatsmeow.go (on main) calls these "unsynchronized service-wide maps" and notes that touching them from another goroutine "risks a fatal error: concurrent map writes". That workaround protects a single call site; the maps stay unguarded everywhere else.

Confirmed in production, twice, by two independent deployments

@Matheusagostinho reported on #127 (2026-08-04), running 0.7.2 with 5 instances:

fatal error: concurrent map read and map write
fatal error: concurrent map writes

with RestartCount: 2 matching the two fatals, and a goroutine dump through instance.Connect / handleQRCodes / webhookProducer.Produce — concurrent connects while another instance was in an active QR flow.

We hit the same thing on our own deployment, and it is severe in a way the crash trace does not show: restarting the container does not bring the instances back. They stay down until an explicit POST /instance/connect per instance, so every crash is a full outage until someone reconnects them by hand.

Change

A single exported sync.RWMutex in the whatsmeow service package, guarding the three maps everywhere they are touched. Lock scopes are kept minimal: values are copied out under RLock and the lock is released before any long call, so no network I/O happens while holding it.

Notes on porting to develop

Four hunks from #127 did not carry over because the code does not exist on this branch: teardownQR, SubmitPasskeyResponse and ConfirmPasskey. Everything else applied. Since main and develop have no common ancestor, I did not assume the site lists matched — I audited all 61 accesses to the three maps on develop and confirmed none was left unguarded, and that every Lock/RLock has its matching release in the same file.

go build ./... and go vet ./pkg/... clean.

@Matheusagostinho — a minimal reproduction with parallel /instance/connect calls would still be valuable, if the offer stands. It would make this much easier to accept than two independent field reports.

Summary by Sourcery

Guard shared instance-level client, MyClient, and kill-channel maps with a global RWMutex across all services to prevent concurrent map access crashes.

Bug Fixes:

  • Prevent runtime fatal errors from concurrent reads and writes to shared instance maps by synchronizing all accesses with a RWMutex.

Enhancements:

  • Introduce a shared synchronization primitive and refactor service code to copy map values under lock and perform network or blocking operations after releasing it, ensuring consistent teardown and cleanup semantics across instances.

`killChannel`, `clientPointer` and `myClientPointer` are created in `main.go` and
shared by reference across all service packages and every `MyClient`.
`ReconnectClient` deletes from all three without a lock, `StartClient` writes to
them, the `Disconnected` handler triggers `ReconnectClient` from a goroutine, and
every HTTP handler reads them concurrently. Go fatals on concurrent map access,
which kills the whole process and every connected instance with it.

This adds a single exported `sync.RWMutex` in the whatsmeow service package,
guarding the three maps everywhere they are touched. Lock scopes are kept
minimal: values are copied out under `RLock` and the lock is released before any
long call, so no network I/O happens while holding it.

This is the `develop` counterpart of evolution-foundation#127, which targets `main`. The four hunks
that did not carry over are for code that does not exist on this branch
(`teardownQR`, `SubmitPasskeyResponse`, `ConfirmPasskey`); everything else
applied, and I audited all 61 accesses to the three maps on `develop` to confirm
none was left unguarded.

`go build ./...` and `go vet ./pkg/...` clean.
@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a shared RWMutex to guard the three global instance-keyed maps (clientPointer, myClientPointer, killChannel) and updates all read/write sites across services to use it, carefully copying values under RLock and doing potentially blocking work after releasing the lock.

Sequence diagram for ReconnectClient map access with ClientMapsMu

sequenceDiagram
    participant WhatsmeowService as whatsmeowService.ReconnectClient
    participant ClientMapsMu as ClientMapsMu
    participant ClientMaps as clientPointer_myClientPointer_killChannel

    WhatsmeowService->>ClientMapsMu: RLock()
    WhatsmeowService->>ClientMaps: read clientPointer[instanceId]
    WhatsmeowService->>ClientMaps: read myClientPointer[instanceId]
    WhatsmeowService->>ClientMapsMu: RUnlock()

    alt client exists
        WhatsmeowService->>WhatsmeowService: client.IsConnected()/Disconnect()
        WhatsmeowService->>WhatsmeowService: client.RemoveEventHandler(mycli.eventHandlerID)
    end

    WhatsmeowService->>ClientMapsMu: RLock()
    WhatsmeowService->>ClientMaps: read killChannel[instanceId]
    WhatsmeowService->>ClientMapsMu: RUnlock()
    WhatsmeowService->>WhatsmeowService: select on killChan

    WhatsmeowService->>ClientMapsMu: Lock()
    WhatsmeowService->>ClientMaps: delete clientPointer[instanceId]
    WhatsmeowService->>ClientMaps: delete myClientPointer[instanceId]
    WhatsmeowService->>ClientMaps: delete killChannel[instanceId]
    WhatsmeowService->>ClientMapsMu: Unlock()
Loading

File-Level Changes

Change Details Files
Introduce a shared RWMutex to consistently guard instance-wide client/killChannel maps and document its usage constraints.
  • Add ClientMapsMu as a package-level sync.RWMutex in the whatsmeow service package.
  • Document which maps are guarded, how they are shared across packages/MyClient instances, and the rule to never hold the lock across long or blocking calls.
  • Standardize the convention that all map reads use RLock/RUnlock and all writes use Lock/Unlock.
pkg/whatsmeow/service/client_maps.go
Refactor whatsmeow service map access to use the RWMutex and avoid holding it during network or blocking operations.
  • Wrap reads of clientPointer, myClientPointer, and killChannel in ReconnectClient, StartClient, StartInstance, ClearInstanceCache, schedulePresenceUpdates, and MyClient.myEventHandler with ClientMapsMu.RLock/RUnlock, copying values to locals before use.
  • Guard all deletes and writes to the three maps with ClientMapsMu.Lock/Unlock, grouping related deletes (e.g., for an instance teardown) under a single critical section.
  • Ensure killChannel sends/selects operate on local copies obtained under RLock, with the lock released before the select/send to avoid blocking under lock.
pkg/whatsmeow/service/whatsmeow.go
Guard instance service’s shared clientPointer and killChannel maps with the RWMutex across connection lifecycle operations.
  • Protect all clientPointer reads in ensureClientConnected, Connect, Status, GetQr, Pair, GetAll, Info, Delete, and ForceReconnect with ClientMapsMu.RLock/RUnlock.
  • Protect writes and deletes to clientPointer and killChannel (including channel creation and cleanup in Connect, Logout, Delete, and ForceReconnect) with ClientMapsMu.Lock/Unlock.
  • Ensure killChannel sends/selects (e.g., in Disconnect, Logout, ForceReconnect) use local channel variables captured under RLock before sending, and that channel closing happens after releasing the lock.
pkg/instance/service/instance_service.go
Guard sendMessage service’s access to the shared clientPointer map with the RWMutex and avoid mid-send client swaps.
  • Use ClientMapsMu.RLock/RUnlock around clientPointer reads in ensureClientConnected and its retry path.
  • In SendMessage, fetch the client once into a local variable under RLock and use that for all subsequent GenerateMessageID, presence, group info, send, and media download calls, preventing races with concurrent reconnects mid-send.
pkg/sendMessage/service/send_service.go
Guard all other feature services’ ensureClientConnected helpers with the RWMutex when accessing the shared clientPointer map.
  • Wrap clientPointer reads in call, chat, community, group, label, message, newsletter, and user services’ ensureClientConnected functions with ClientMapsMu.RLock/RUnlock.
  • Ensure the retry-after-start paths in these helpers re-read clientPointer under the same lock pattern to avoid concurrent read/write races with StartClient/ReconnectClient.
pkg/call/service/call_service.go
pkg/chat/service/chat_service.go
pkg/community/service/community_service.go
pkg/group/service/group_service.go
pkg/label/service/label_service.go
pkg/message/service/message_service.go
pkg/newsletter/service/newsletter_service.go
pkg/user/service/user_service.go

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 found 1 issue, and left some high level feedback:

  • Given how widely ClientMapsMu is now used, consider encapsulating the shared maps behind a small helper type with Get/Set/Delete methods instead of exporting a bare mutex, so that future call sites are forced through a single, safer API and it’s harder to accidentally bypass locking.
  • Patterns like killChan := m[id]; ... if killChan != nil { killChan <- true; close(killChan) } are repeated with subtle variations across services; extracting a shared helper for sending/closing kill channels would reduce duplication and make it easier to reason about their lifecycle and avoid edge-case races.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Given how widely `ClientMapsMu` is now used, consider encapsulating the shared maps behind a small helper type with `Get/Set/Delete` methods instead of exporting a bare mutex, so that future call sites are forced through a single, safer API and it’s harder to accidentally bypass locking.
- Patterns like `killChan := m[id]; ... if killChan != nil { killChan <- true; close(killChan) }` are repeated with subtle variations across services; extracting a shared helper for sending/closing kill channels would reduce duplication and make it easier to reason about their lifecycle and avoid edge-case races.

## Individual Comments

### Comment 1
<location path="pkg/instance/service/instance_service.go" line_range="119-124" />
<code_context>
 }

 func (c *callService) ensureClientConnected(instanceId string) (*whatsmeow.Client, error) {
+	whatsmeow_service.ClientMapsMu.RLock()
 	client := c.clientPointer[instanceId]
+	whatsmeow_service.ClientMapsMu.RUnlock()
</code_context>
<issue_to_address>
**issue (bug_risk):** Guard against nil client before calling IsConnected/IsLoggedIn in ForceReconnect

`forceReconnectClient` is read from the map and used without a nil check. If the instance ID is missing or the entry was cleared, this can panic when calling `IsConnected()`.

You can keep the existing locking semantics while hardening the code by returning an explicit error when the client is missing, e.g.:

```go
whatsmeow_service.ClientMapsMu.RLock()
forceReconnectClient := i.clientPointer[instanceId]
whatsmeow_service.ClientMapsMu.RUnlock()

if forceReconnectClient == nil {
    return fmt.Errorf("client %s not found for force reconnect", instanceId)
}

if forceReconnectClient.IsConnected() && forceReconnectClient.IsLoggedIn() {
    return fmt.Errorf("client already connected")
}
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +119 to 124
whatsmeow_service.ClientMapsMu.RLock()
client := i.clientPointer[instanceId]
whatsmeow_service.ClientMapsMu.RUnlock()
logger.LogInfo("[%s] Checking client connection status - Client exists: %v", instanceId, client != nil)

if client == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): Guard against nil client before calling IsConnected/IsLoggedIn in ForceReconnect

forceReconnectClient is read from the map and used without a nil check. If the instance ID is missing or the entry was cleared, this can panic when calling IsConnected().

You can keep the existing locking semantics while hardening the code by returning an explicit error when the client is missing, e.g.:

whatsmeow_service.ClientMapsMu.RLock()
forceReconnectClient := i.clientPointer[instanceId]
whatsmeow_service.ClientMapsMu.RUnlock()

if forceReconnectClient == nil {
    return fmt.Errorf("client %s not found for force reconnect", instanceId)
}

if forceReconnectClient.IsConnected() && forceReconnectClient.IsLoggedIn() {
    return fmt.Errorf("client already connected")
}

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