Skip to content

Commit bc5edee

Browse files
Copilotaooohan
andauthored
fix: concurrent map writes in Manager.LookupSdk (#610)
* Initial plan * Fix concurrent map writes in Manager with RWMutex Co-authored-by: aooohan <40265686+aooohan@users.noreply.github.com> * Add test simulating activate.go concurrent SDK lookups Co-authored-by: aooohan <40265686+aooohan@users.noreply.github.com> * Address code review feedback: fix cache key consistency and Close() locking Co-authored-by: aooohan <40265686+aooohan@users.noreply.github.com> * Add comments documenting zero-value mutex initialization Co-authored-by: aooohan <40265686+aooohan@users.noreply.github.com> * Improve consistency: use normalizedName variable in LoadAllSdk Co-authored-by: aooohan <40265686+aooohan@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: aooohan <40265686+aooohan@users.noreply.github.com>
1 parent 799c95a commit bc5edee

2 files changed

Lines changed: 315 additions & 4 deletions

File tree

‎internal/manager.go‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"sort"
3030
"strconv"
3131
"strings"
32+
"sync"
3233
"time"
3334

3435
"github.com/pterm/pterm"
@@ -67,20 +68,29 @@ type Arg struct {
6768
type Manager struct {
6869
RuntimeEnvContext *env.RuntimeEnvContext // runtime environment context
6970
openSdks map[string]sdk.Sdk
71+
mu sync.RWMutex // protects openSdks map
7072
}
7173

7274
// LookupSdk lookup sdk by name
7375
// Loads SDK plugins and generates env keys based on the tool versions in the chain
7476
func (m *Manager) LookupSdk(name string) (sdk.Sdk, error) {
7577
logger.Debugf("Looking up SDK: %s\n", name)
7678

77-
if s, ok := m.openSdks[name]; ok {
79+
// Normalize the name for cache lookup
80+
normalizedName := strings.ToLower(name)
81+
82+
// Check cache with read lock
83+
m.mu.RLock()
84+
s, ok := m.openSdks[normalizedName]
85+
m.mu.RUnlock()
86+
87+
if ok {
7888
logger.Debugf("SDK %s found in cache\n", name)
7989
return s, nil
8090
}
8191

8292
// Query plugin directly from shared root
83-
pluginPath := filepath.Join(m.RuntimeEnvContext.PathMeta.Shared.Plugins, strings.ToLower(name))
93+
pluginPath := filepath.Join(m.RuntimeEnvContext.PathMeta.Shared.Plugins, normalizedName)
8494
logger.Debugf("Checking plugin path: %s\n", pluginPath)
8595

8696
if !util.FileExists(pluginPath) {
@@ -95,7 +105,11 @@ func (m *Manager) LookupSdk(name string) (sdk.Sdk, error) {
95105
return nil, err
96106
}
97107

98-
m.openSdks[strings.ToLower(name)] = s
108+
// Add to cache with write lock
109+
m.mu.Lock()
110+
m.openSdks[normalizedName] = s
111+
m.mu.Unlock()
112+
99113
logger.Debugf("SDK %s loaded and cached successfully\n", name)
100114
return s, nil
101115
}
@@ -182,14 +196,20 @@ func (m *Manager) LoadAllSdk() ([]sdk.Sdk, error) {
182196

183197
for _, f := range files {
184198
sdkName := f.Name()
199+
normalizedName := strings.ToLower(sdkName)
185200
path := filepath.Join(dir, sdkName)
186201

187202
if f.IsDir() {
188203
logger.Debugf("Loading SDK: %s from %s\n", sdkName, path)
189204
s, err := sdk.NewSdk(m.RuntimeEnvContext, path)
190205
if err == nil {
191206
sdkSlice = append(sdkSlice, s)
192-
m.openSdks[strings.ToLower(sdkName)] = s
207+
208+
// Add to cache with write lock
209+
m.mu.Lock()
210+
m.openSdks[normalizedName] = s
211+
m.mu.Unlock()
212+
193213
logger.Debugf("SDK %s loaded successfully\n", sdkName)
194214
} else {
195215
logger.Debugf("Failed to load SDK %s: %v\n", sdkName, err)
@@ -209,6 +229,11 @@ func (m *Manager) LoadAllSdk() ([]sdk.Sdk, error) {
209229
}
210230

211231
func (m *Manager) Close() {
232+
// Use write lock to ensure no other operations are in progress
233+
// while we're closing SDK handlers
234+
m.mu.Lock()
235+
defer m.mu.Unlock()
236+
212237
for _, handler := range m.openSdks {
213238
handler.Close()
214239
}
@@ -796,6 +821,7 @@ func NewSdkManager() (*Manager, error) {
796821
RuntimeVersion: RuntimeVersion,
797822
},
798823
openSdks: make(map[string]sdk.Sdk),
824+
// mu is intentionally zero-initialized (Go's zero-value mutex is ready to use)
799825
}, nil
800826
}
801827

‎internal/manager_test.go‎

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
/*
2+
* Copyright 2026 Han Li and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package internal
18+
19+
import (
20+
"os"
21+
"path/filepath"
22+
"sync"
23+
"testing"
24+
25+
"github.com/version-fox/vfox/internal/config"
26+
"github.com/version-fox/vfox/internal/env"
27+
"github.com/version-fox/vfox/internal/pathmeta"
28+
"github.com/version-fox/vfox/internal/sdk"
29+
)
30+
31+
// Note: In these tests, Manager instances are created without explicitly
32+
// initializing the mu field. This is intentional and valid in Go, as the
33+
// zero-value of sync.RWMutex is ready to use.
34+
35+
// TestLookupSdk_ConcurrentAccess tests that concurrent calls to LookupSdk
36+
// do not cause race conditions or panics
37+
func TestLookupSdk_ConcurrentAccess(t *testing.T) {
38+
// Create a temporary directory structure for testing
39+
tmpDir := t.TempDir()
40+
userHome := filepath.Join(tmpDir, "user_home")
41+
vfoxHome := filepath.Join(tmpDir, "vfox_home")
42+
currentDir := tmpDir
43+
44+
// Create necessary directories
45+
os.MkdirAll(userHome, 0755)
46+
os.MkdirAll(vfoxHome, 0755)
47+
48+
// Create PathMeta
49+
meta, err := pathmeta.NewPathMeta(userHome, vfoxHome, currentDir, 12345)
50+
if err != nil {
51+
t.Fatalf("Failed to create PathMeta: %v", err)
52+
}
53+
54+
// Create plugins directory
55+
pluginsDir := meta.Shared.Plugins
56+
os.MkdirAll(pluginsDir, 0755)
57+
58+
// Create Manager
59+
manager := &Manager{
60+
RuntimeEnvContext: &env.RuntimeEnvContext{
61+
UserConfig: &config.Config{},
62+
CurrentWorkingDir: currentDir,
63+
PathMeta: meta,
64+
RuntimeVersion: "test",
65+
},
66+
openSdks: make(map[string]sdk.Sdk),
67+
}
68+
69+
// Test concurrent access with non-existent SDK
70+
// This should not cause race conditions or panics
71+
const numGoroutines = 10
72+
var wg sync.WaitGroup
73+
wg.Add(numGoroutines)
74+
75+
for i := 0; i < numGoroutines; i++ {
76+
go func(id int) {
77+
defer wg.Done()
78+
// Try to lookup a non-existent SDK
79+
_, err := manager.LookupSdk("nonexistent")
80+
// We expect an error since the SDK doesn't exist
81+
if err == nil {
82+
t.Errorf("Expected error for non-existent SDK, got nil")
83+
}
84+
}(i)
85+
}
86+
87+
wg.Wait()
88+
}
89+
90+
// TestLookupSdk_ConcurrentCacheHits tests that concurrent cache hits
91+
// do not cause race conditions
92+
func TestLookupSdk_ConcurrentCacheHits(t *testing.T) {
93+
// Create a temporary directory structure for testing
94+
tmpDir := t.TempDir()
95+
userHome := filepath.Join(tmpDir, "user_home")
96+
vfoxHome := filepath.Join(tmpDir, "vfox_home")
97+
currentDir := tmpDir
98+
99+
// Create necessary directories
100+
os.MkdirAll(userHome, 0755)
101+
os.MkdirAll(vfoxHome, 0755)
102+
103+
// Create PathMeta
104+
meta, err := pathmeta.NewPathMeta(userHome, vfoxHome, currentDir, 12345)
105+
if err != nil {
106+
t.Fatalf("Failed to create PathMeta: %v", err)
107+
}
108+
109+
// Create Manager
110+
manager := &Manager{
111+
RuntimeEnvContext: &env.RuntimeEnvContext{
112+
UserConfig: &config.Config{},
113+
CurrentWorkingDir: currentDir,
114+
PathMeta: meta,
115+
RuntimeVersion: "test",
116+
},
117+
openSdks: make(map[string]sdk.Sdk),
118+
}
119+
120+
// Pre-populate the cache with a nil SDK (for testing purposes)
121+
// In real usage, this would be a valid SDK object
122+
// Use proper locking to avoid race conditions in the test itself
123+
manager.mu.Lock()
124+
manager.openSdks["test"] = nil
125+
manager.mu.Unlock()
126+
127+
// Test concurrent cache reads
128+
const numGoroutines = 20
129+
var wg sync.WaitGroup
130+
wg.Add(numGoroutines)
131+
132+
for i := 0; i < numGoroutines; i++ {
133+
go func(id int) {
134+
defer wg.Done()
135+
// Try to lookup the cached SDK
136+
_, err := manager.LookupSdk("test")
137+
if err != nil {
138+
// Error is expected since we stored nil
139+
// But we should not panic or race
140+
}
141+
}(i)
142+
}
143+
144+
wg.Wait()
145+
}
146+
147+
// TestLoadAllSdk_NoRaceCondition verifies that LoadAllSdk
148+
// can safely populate the cache
149+
func TestLoadAllSdk_NoRaceCondition(t *testing.T) {
150+
// Create a temporary directory structure for testing
151+
tmpDir := t.TempDir()
152+
userHome := filepath.Join(tmpDir, "user_home")
153+
vfoxHome := filepath.Join(tmpDir, "vfox_home")
154+
currentDir := tmpDir
155+
156+
// Create necessary directories
157+
os.MkdirAll(userHome, 0755)
158+
os.MkdirAll(vfoxHome, 0755)
159+
160+
// Create PathMeta
161+
meta, err := pathmeta.NewPathMeta(userHome, vfoxHome, currentDir, 12345)
162+
if err != nil {
163+
t.Fatalf("Failed to create PathMeta: %v", err)
164+
}
165+
166+
// Create plugins directory
167+
pluginsDir := meta.Shared.Plugins
168+
os.MkdirAll(pluginsDir, 0755)
169+
170+
// Create Manager
171+
manager := &Manager{
172+
RuntimeEnvContext: &env.RuntimeEnvContext{
173+
UserConfig: &config.Config{},
174+
CurrentWorkingDir: currentDir,
175+
PathMeta: meta,
176+
RuntimeVersion: "test",
177+
},
178+
openSdks: make(map[string]sdk.Sdk),
179+
}
180+
181+
// Call LoadAllSdk (will return empty list but shouldn't panic)
182+
sdks, err := manager.LoadAllSdk()
183+
if err != nil {
184+
t.Fatalf("LoadAllSdk failed: %v", err)
185+
}
186+
187+
// Should return empty list for empty plugins directory
188+
if len(sdks) != 0 {
189+
t.Errorf("Expected 0 SDKs, got %d", len(sdks))
190+
}
191+
}
192+
193+
// TestClose_NoRaceCondition verifies that Close
194+
// can safely iterate over the cache
195+
func TestClose_NoRaceCondition(t *testing.T) {
196+
// Create a temporary directory structure for testing
197+
tmpDir := t.TempDir()
198+
userHome := filepath.Join(tmpDir, "user_home")
199+
vfoxHome := filepath.Join(tmpDir, "vfox_home")
200+
currentDir := tmpDir
201+
202+
// Create necessary directories
203+
os.MkdirAll(userHome, 0755)
204+
os.MkdirAll(vfoxHome, 0755)
205+
206+
// Create PathMeta
207+
meta, err := pathmeta.NewPathMeta(userHome, vfoxHome, currentDir, 12345)
208+
if err != nil {
209+
t.Fatalf("Failed to create PathMeta: %v", err)
210+
}
211+
212+
// Create Manager
213+
manager := &Manager{
214+
RuntimeEnvContext: &env.RuntimeEnvContext{
215+
UserConfig: &config.Config{},
216+
CurrentWorkingDir: currentDir,
217+
PathMeta: meta,
218+
RuntimeVersion: "test",
219+
},
220+
openSdks: make(map[string]sdk.Sdk),
221+
}
222+
223+
// Call Close (should not panic even with empty cache)
224+
manager.Close()
225+
}
226+
227+
// TestLookupSdk_SimulateActivateScenario simulates the exact scenario from activate.go
228+
// where multiple goroutines lookup different SDKs concurrently
229+
func TestLookupSdk_SimulateActivateScenario(t *testing.T) {
230+
// Create a temporary directory structure for testing
231+
tmpDir := t.TempDir()
232+
userHome := filepath.Join(tmpDir, "user_home")
233+
vfoxHome := filepath.Join(tmpDir, "vfox_home")
234+
currentDir := tmpDir
235+
236+
// Create necessary directories
237+
os.MkdirAll(userHome, 0755)
238+
os.MkdirAll(vfoxHome, 0755)
239+
240+
// Create PathMeta
241+
meta, err := pathmeta.NewPathMeta(userHome, vfoxHome, currentDir, 12345)
242+
if err != nil {
243+
t.Fatalf("Failed to create PathMeta: %v", err)
244+
}
245+
246+
// Create plugins directory
247+
pluginsDir := meta.Shared.Plugins
248+
os.MkdirAll(pluginsDir, 0755)
249+
250+
// Create Manager
251+
manager := &Manager{
252+
RuntimeEnvContext: &env.RuntimeEnvContext{
253+
UserConfig: &config.Config{},
254+
CurrentWorkingDir: currentDir,
255+
PathMeta: meta,
256+
RuntimeVersion: "test",
257+
},
258+
openSdks: make(map[string]sdk.Sdk),
259+
}
260+
261+
// Simulate the activate.go scenario with multiple SDKs being looked up concurrently
262+
// This is what was causing the concurrent map writes error
263+
sdkNames := []string{"nodejs", "python", "java", "go", "ruby", "rust"}
264+
265+
var wg sync.WaitGroup
266+
wg.Add(len(sdkNames))
267+
268+
for _, sdkName := range sdkNames {
269+
sdkName := sdkName // Capture loop variable
270+
go func() {
271+
defer wg.Done()
272+
// Each goroutine tries to lookup a different SDK
273+
// This simulates the errgroup in activate.go
274+
_, err := manager.LookupSdk(sdkName)
275+
// We expect errors since these SDKs don't exist
276+
// But we should not panic or race
277+
if err == nil {
278+
t.Errorf("Expected error for non-existent SDK %s, got nil", sdkName)
279+
}
280+
}()
281+
}
282+
283+
wg.Wait()
284+
}
285+

0 commit comments

Comments
 (0)