Skip to content

fix(concurrency): guard the three shared maps with a mutex - #196

Open
EcoosUP wants to merge 1 commit into
evolution-foundation:mainfrom
EcoosUP:fix/data-race-shared-maps
Open

fix(concurrency): guard the three shared maps with a mutex#196
EcoosUP wants to merge 1 commit into
evolution-foundation:mainfrom
EcoosUP:fix/data-race-shared-maps

Conversation

@EcoosUP

@EcoosUP EcoosUP commented Sep 10, 2026

Copy link
Copy Markdown

The problem

clientPointer, myClientPointer and killChannel are plain Go maps, created once at startup and passed by reference into eleven packages. All of them read and write those maps from different goroutines, with no synchronisation.

Go does not tolerate that. Concurrent writes do not corrupt silently — the runtime kills the process:

fatal error: concurrent map writes

goroutine 45 [running]:
internal/runtime/maps.fatal(...)
github.com/evolution-foundation/evolution-go/pkg/whatsmeow/service.whatsmeowService.StartClient(...)
	/build/pkg/whatsmeow/service/whatsmeow.go:550 +0xe8f
created by ...StartInstance in goroutine 61

Line 550 is w.clientPointer[cd.Instance.Id] = client. StartInstance launches one goroutine per instance, so the window opens whenever more than a couple of instances connect at the same time — most commonly at startup, since CONNECT_ON_STARTUP brings them all up together.

A fatal error is not recoverable. A deferred recover() does not catch it, so no amount of defensive code around the call site helps.

Measured

Our process died exactly this way while five instances were connecting. The orchestrator restarted it within seconds, but every number was offline in the meantime, and it recurs on any restart of that size.

The fix

The three maps become safemap.Map[T], a small generic wrapper over a sync.RWMutex:

plain map safemap
m[k] m.Get(k)
v, ok := m[k] m.Lookup(k)
m[k] = v m.Set(k, v)
delete(m, k) m.Delete(k)
len(m) m.Len()
range m range m.Snapshot()

Get returns the zero value for an absent key, exactly like m[k], so swapping the call sites changes no behaviour. There is a test pinning that.

Why a separate package. MyClient is defined in pkg/whatsmeow/service. Putting the type there would force the other ten packages to import it and create an import cycle. pkg/safemap has no internal dependencies.

Why Snapshot for ranges. Ranging from inside would hold the lock for the whole loop, and these loop bodies talk to WhatsApp — the lock would be held across network calls.

Scope

98 call sites across 12 files, plus the new package. The changes are mechanical and the compiler is the judge: anything missed is a build error, not a silent bug.

Testing

  • go build ./... against main, clean.
  • go test -race ./pkg/safemap/ passes.
  • The test file includes a calibration case (skipped by default) that runs the same exercise on a plain map. Run it on purpose and the detector reports DATA RACE and fails — proof that the detector is not blind before trusting the green result.

Summary by Sourcery

Protect shared runtime state from concurrent goroutine access by replacing the process-wide maps with synchronized generic maps.

Bug Fixes:

  • Prevent process crashes caused by concurrent access to the shared client, MyClient, and kill-channel maps.

Enhancements:

  • Replace shared plain maps with a generic mutex-protected map abstraction and update all consumers to use synchronized access.

Tests:

  • Add race-focused safemap tests, including coverage for concurrent access and zero-value reads on missing keys.

clientPointer, myClientPointer and killChannel are created once at startup and
passed by reference into eleven packages. All of them read and wrote those plain
Go maps from different goroutines, with no synchronisation.

Go does not tolerate that: the runtime kills the process with

    fatal error: concurrent map writes

and a fatal error is not recoverable — a deferred recover() does not catch it.

Measured in production: the process died inside StartClient while five instances
were connecting at once. The orchestrator restarted it within seconds, but every
number was offline in the meantime, and it repeats on any restart that brings up
more than a couple of instances together.

The three maps become safemap.Map[T], a generic wrapper over a RWMutex. It lives
in its own package because MyClient is defined in pkg/whatsmeow/service, and
putting the type there would create an import cycle with the ten packages that
receive the maps.

98 call sites changed across 12 files, with the compiler as the judge: anything
missed is a build error, not a silent bug.

Tests: with -race, a plain map running the same exercise reports DATA RACE and
fails, while safemap.Map passes. Both halves verified.
@sourcery-ai

sourcery-ai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Reviewer's Guide

Replaces the three unsynchronized process-wide maps with pointer-shared generic maps guarded by RWMutexes, then mechanically updates all consumers to use synchronized accessors while preserving map semantics and safe snapshot-based iteration. Dedicated race-enabled tests validate concurrent access and zero-value reads.

Sequence diagram for concurrent client startup

sequenceDiagram
    participant InstanceService
    participant WhatsmeowService
    participant ClientMap as safemap.Map[Client]
    participant MyClientMap as safemap.Map[MyClient]

    par instance A startup
        InstanceService->>WhatsmeowService: StartClient(cd)
        WhatsmeowService->>ClientMap: Set(instanceId, client)
        ClientMap-->>WhatsmeowService: write completes
        WhatsmeowService->>MyClientMap: Set(instanceId, mycli)
        MyClientMap-->>WhatsmeowService: write completes
    and instance B startup
        InstanceService->>WhatsmeowService: StartClient(cd)
        WhatsmeowService->>ClientMap: Set(instanceId, client)
        ClientMap-->>WhatsmeowService: RWMutex serializes write
        WhatsmeowService->>MyClientMap: Set(instanceId, mycli)
        MyClientMap-->>WhatsmeowService: RWMutex serializes write
    end
Loading

Flow diagram for safe map reads and iteration

flowchart TD
    Read[Consumer needs shared state] --> Lookup{Single-key access?}
    Lookup -->|yes| Get[Get or Lookup]
    Lookup -->|write| Set[Set or Delete]
    Lookup -->|range| Snapshot[Snapshot]
    Get --> ReadLock[RWMutex read lock]
    Snapshot --> Copy[Copy map under read lock]
    Copy --> Iterate[Range copied map]
    Iterate --> Network[Perform WhatsApp or network calls without holding map lock]
    Set --> WriteLock[RWMutex write lock]
Loading

File-Level Changes

Change Details Files
Introduces a generic mutex-protected map abstraction for concurrent shared state.
  • Adds RWMutex-guarded Get, Lookup, Set, Delete, Len, and Snapshot operations.
  • Preserves absent-key zero-value behavior and avoids holding locks during external/network work by copying before iteration.
  • Adds concurrent-access, missing-key semantics, and opt-in race-calibration tests.
pkg/safemap/safemap.go
pkg/safemap/safemap_test.go
Migrates process-wide client and lifecycle maps to shared safemap instances.
  • Initializes killChannel and clientPointer as safemap maps at startup and initializes myClientPointer in the WhatsApp service.
  • Changes service fields and constructors across the application to pass pointers to the synchronized generic maps.
  • Replaces direct map reads, writes, lookups, deletions, and channel-map access with the corresponding safemap APIs.
cmd/evolution-go/main.go
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/instance/service/instance_service.go
pkg/label/service/label_service.go
pkg/message/service/message_service.go
pkg/newsletter/service/newsletter_service.go
pkg/sendMessage/service/send_service.go
pkg/user/service/user_service.go
pkg/whatsmeow/service/whatsmeow.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 3 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="pkg/whatsmeow/service/whatsmeow.go" line_range="312-313" />
<code_context>

-	if w.clientPointer[cd.Instance.Id] != nil {
-		if w.clientPointer[cd.Instance.Id].IsConnected() {
+	if w.clientPointer.Get(cd.Instance.Id) != nil {
+		if w.clientPointer.Get(cd.Instance.Id).IsConnected() {
 			return
 		}
</code_context>
<issue_to_address>
**issue (bug_risk):** The initial `Get` and later `Set` are not atomic, so two concurrent `StartClient` calls for the same instance both pass the connected check and create clients; the later `Set` overwrites the first pointer while the first client's connection and event handler remain active and unmanaged.

**Triggers:** When the same instance is started or reconnected concurrently.

**Suggested fix:** Serialize start/reconnect per instance, or add an atomic compare-and-set/claim operation to the synchronized map.
</issue_to_address>

### Comment 2
<location path="pkg/whatsmeow/service/whatsmeow.go" line_range="2386" />
<code_context>
 	}

-	w.killChannel[instance.Id] = make(chan bool)
+	w.killChannel.Set(instance.Id, make(chan bool))

 	clientData := &ClientData{
</code_context>
<issue_to_address>
**issue (bug_risk):** `StartInstance` replaces the existing kill channel without coordinating with the existing `StartClient` goroutine. Concurrent starts can leave multiple clients listening to one replacement channel while the original channel is orphaned, so a single kill signal stops only one client and leaves another running.

**Triggers:** When the same instance is started more than once before the previous client exits.

**Suggested fix:** Make channel installation and client startup a per-instance atomic lifecycle operation, or reject a start while an existing client is active.
</issue_to_address>

### Comment 3
<location path="pkg/safemap/safemap_test.go" line_range="22" />
<code_context>
+//
+//	go test -race -run TestPlainMapRaces ./pkg/safemap/
+func TestPlainMapRaces(t *testing.T) {
+	t.Skip("calibration: run by hand — see the comment above")
+
+	m := map[string]int{}
</code_context>
<issue_to_address>
**issue (testing):** The calibration test unconditionally calls `t.Skip`, so the documented `go test -race -run TestPlainMapRaces ./pkg/safemap/` command never executes the plain-map race and cannot verify that the detector catches the intended failure.

**Triggers:** When relying on the documented calibration command to validate race-detector coverage.

**Suggested fix:** Gate the skip behind an explicit environment variable or separate the calibration test into a manually enabled test target.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 3 findings to address first, and this changes shared runtime state across many services, and individual map operations are locked but sequences such as Get-then-send or Get-then-close are not atomic; a concurrent logout or cleanup could still panic on a closed channel or act on a stale client. Reverting removes the new race behavior, but any crash or service outage that already occurred would not be undone.

Blocking findings: pkg/whatsmeow/service/whatsmeow.go:313, pkg/whatsmeow/service/whatsmeow.go:2386, pkg/safemap/safemap_test.go:22


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

Comment on lines +312 to +313
if w.clientPointer.Get(cd.Instance.Id) != nil {
if w.clientPointer.Get(cd.Instance.Id).IsConnected() {

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): The initial Get and later Set are not atomic, so two concurrent StartClient calls for the same instance both pass the connected check and create clients; the later Set overwrites the first pointer while the first client's connection and event handler remain active and unmanaged.

Triggers: When the same instance is started or reconnected concurrently.

Suggested fix: Serialize start/reconnect per instance, or add an atomic compare-and-set/claim operation to the synchronized map.

}

w.killChannel[instance.Id] = make(chan bool)
w.killChannel.Set(instance.Id, make(chan bool))

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): StartInstance replaces the existing kill channel without coordinating with the existing StartClient goroutine. Concurrent starts can leave multiple clients listening to one replacement channel while the original channel is orphaned, so a single kill signal stops only one client and leaves another running.

Triggers: When the same instance is started more than once before the previous client exits.

Suggested fix: Make channel installation and client startup a per-instance atomic lifecycle operation, or reject a start while an existing client is active.

//
// go test -race -run TestPlainMapRaces ./pkg/safemap/
func TestPlainMapRaces(t *testing.T) {
t.Skip("calibration: run by hand — see the comment above")

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 (testing): The calibration test unconditionally calls t.Skip, so the documented go test -race -run TestPlainMapRaces ./pkg/safemap/ command never executes the plain-map race and cannot verify that the detector catches the intended failure.

Triggers: When relying on the documented calibration command to validate race-detector coverage.

Suggested fix: Gate the skip behind an explicit environment variable or separate the calibration test into a manually enabled test target.

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.

2 participants