Skip to content

fix: data race and nil deref reading S3 bucket config#331

Merged
asternic merged 3 commits into
asternic:mainfrom
ThiagoBauken:fix/s3-config-race
Jun 5, 2026
Merged

fix: data race and nil deref reading S3 bucket config#331
asternic merged 3 commits into
asternic:mainfrom
ThiagoBauken:fix/s3-config-race

Conversation

@ThiagoBauken

Copy link
Copy Markdown
Contributor

What & why

ProcessMediaForS3 read m.configs[userID].Bucket without holding m.mu, while that map is written under the lock from SetS3Config / InitializeS3Client / RemoveClient. That is a data race, and if the config was removed between the upload and the metadata build, it nil-dereferenced and crashed the process.

Change

The read now goes through bucketFor, which takes the RLock and returns an empty string when there is no config (no nil deref). Mirrors the locking already used by GetClient.

Testing

  • TestBucketFor — returns the bucket when configured, empty (never a nil panic) when not. Red→green: the old direct read nil-panics on a missing config.
  • TestBucketForConcurrent — exercises the read against concurrent writers under go test -race.
  • go build ./... · go vet ./... · go test ./... — pass on host.
  • Full suite + go test -race ./... — pass on linux/amd64 (Docker golang:1.25).

No API change — internal stability fix, same class as #325.

ProcessMediaForS3 read m.configs[userID].Bucket without holding m.mu while the
configs map is written under the lock from SetS3Config/InitializeS3Client/
RemoveClient. That is a data race, and if the config was removed between the
upload and the metadata build it nil-dereferenced and crashed. Reads now go
through bucketFor, which takes the RLock and returns an empty string when there
is no config.

Tests: TestBucketFor (nil-safe, red->green) and TestBucketForConcurrent (guards
the read under go test -race). go build/vet/test pass on host.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a thread-safe bucketFor helper method and associated unit tests to prevent concurrent map read/write races when retrieving S3 bucket configurations. The reviewer suggests simplifying the implementation by reusing the existing thread-safe GetClient method instead of adding a new helper, and provides a code suggestion to adapt the concurrent tests accordingly.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread s3manager.go Outdated
Comment on lines +350 to +361
// bucketFor returns the configured bucket for userID, read under the lock so it
// cannot race with a concurrent reconfigure/removal (the configs map is written
// under m.mu elsewhere). Returns an empty string when there is no config instead
// of nil-dereferencing.
func (m *S3Manager) bucketFor(userID string) string {
m.mu.RLock()
defer m.mu.RUnlock()
if cfg, ok := m.configs[userID]; ok && cfg != nil {
return cfg.Bucket
}
return ""
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Instead of introducing a new helper method bucketFor and its corresponding tests, you can reuse the existing thread-safe GetClient method. GetClient already acquires the read lock (m.mu.RLock()) and safely retrieves the configuration. This reduces code duplication and simplifies the codebase.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — done in 71d3be9: dropped the bucketFor helper and now read the bucket through the existing thread-safe GetClient (if _, config, ok := m.GetClient(userID); ok && config != nil). No new locking surface.

Comment thread s3_bucket_test.go Outdated
Comment on lines +11 to +45
func TestBucketFor(t *testing.T) {
m := &S3Manager{configs: map[string]*S3Config{}}

if got := m.bucketFor("nobody"); got != "" {
t.Errorf("no config: got %q; want empty string", got)
}

m.configs["u1"] = &S3Config{Bucket: "my-bucket"}
if got := m.bucketFor("u1"); got != "my-bucket" {
t.Errorf("got %q; want %q", got, "my-bucket")
}
}

// TestBucketForConcurrent runs bucketFor against concurrent writers. The unlocked
// map read this replaced raced with config updates: Go's runtime flags it with
// "concurrent map read and map write" (and the race detector confirms it). The
// locked read passes.
func TestBucketForConcurrent(t *testing.T) {
m := &S3Manager{configs: map[string]*S3Config{}}
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func() {
defer wg.Done()
_ = m.bucketFor("u1")
}()
go func() {
defer wg.Done()
m.mu.Lock()
m.configs["u1"] = &S3Config{Bucket: "b"}
m.mu.Unlock()
}()
}
wg.Wait()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Since bucketFor is removed in favor of GetClient, we can adapt the concurrent test to target GetClient directly. This ensures that the core configuration retrieval path remains thread-safe and free of data races.

func TestGetClientConcurrent(t *testing.T) {
	m := &S3Manager{configs: map[string]*S3Config{}}
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(2)
		go func() {
			defer wg.Done()
			_, _, _ = m.GetClient("u1")
		}()
		go func() {
			defer wg.Done()
			m.mu.Lock()
			m.configs["u1"] = &S3Config{Bucket: "b"}
			m.mu.Unlock()
		}()
	}
	wg.Wait()
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in 71d3be9: the concurrent test is now TestGetClientConcurrent, exercising GetClient directly under -race against a concurrent writer (verified on Linux via Docker).

Addresses Gemini review: instead of a new bucketFor helper, ProcessMediaForS3
now reads the config through the existing GetClient, which already takes the
read lock and returns ok=false when there is no config. Drops the duplicated
locking; the concurrent test now targets GetClient (TestGetClientConcurrent).
@ThiagoBauken

Copy link
Copy Markdown
Contributor Author

Thanks — applied. ProcessMediaForS3 now reads the config through the existing GetClient, which already takes the read lock and returns ok=false when there is no config, so the dedicated bucketFor helper is gone. The concurrent test now targets GetClient (TestGetClientConcurrent). go build/vet/test pass on host and the full suite passes under go test -race on linux/amd64 (Docker).

@asternic asternic merged commit ea89cc2 into asternic:main Jun 5, 2026
1 check passed
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