diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b26b2fa..6bee139 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -96,8 +96,57 @@ jobs: fi echo "✅ go.mod and go.sum are tidy" + race-detection: + name: Race Detection + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Get dependencies + run: go mod download + + - name: Run tests with race detector + run: | + echo "Running tests with Go race detector enabled..." + echo "This detects data races in concurrent code (requires cgo)" + go test -race -v ./src/... 2>&1 | tee race-test-output.log + TEST_EXIT_CODE=${PIPESTATUS[0]} + exit $TEST_EXIT_CODE + + - name: Generate race detection summary + if: always() + run: | + echo "## Race Detection Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if grep -q "WARNING: DATA RACE" race-test-output.log 2>/dev/null; then + echo "❌ **Data races detected!**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Race Details" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + grep -A 50 "WARNING: DATA RACE" race-test-output.log >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + else + echo "✅ **No data races detected**" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "Full Test Output" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat race-test-output.log >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + build: - needs: [golangci-lint, go-mod] + needs: [golangci-lint, go-mod, race-detection] name: Build ${{ matrix.platform }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} strategy: diff --git a/src/internal/config/paths.go b/src/internal/config/paths.go index 6fad0cb..6a07dbf 100644 --- a/src/internal/config/paths.go +++ b/src/internal/config/paths.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "sync" "github.com/dtvem/dtvem/src/internal/constants" ) @@ -17,13 +18,17 @@ type Paths struct { Config string // Config directory (~/.dtvem/config) } -var defaultPaths *Paths +var ( + defaultPaths *Paths + pathsOnce sync.Once +) -// DefaultPaths returns the default dtvem paths +// DefaultPaths returns the default dtvem paths. +// This function is thread-safe and guarantees single initialization. func DefaultPaths() *Paths { - if defaultPaths == nil { + pathsOnce.Do(func() { defaultPaths = initPaths() - } + }) return defaultPaths } diff --git a/src/internal/config/paths_test.go b/src/internal/config/paths_test.go index f59b0a9..d4ed2b0 100644 --- a/src/internal/config/paths_test.go +++ b/src/internal/config/paths_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "testing" ) @@ -172,6 +173,13 @@ func TestShimPath(t *testing.T) { } } +// resetPathsForTesting resets the paths singleton for testing purposes. +// This allows tests to verify behavior with different environment configurations. +func resetPathsForTesting() { + defaultPaths = nil + pathsOnce = sync.Once{} +} + func TestGetRootDir_WithEnvironmentVariable(t *testing.T) { // Save original environment originalRoot := os.Getenv("DTVEM_ROOT") @@ -181,16 +189,16 @@ func TestGetRootDir_WithEnvironmentVariable(t *testing.T) { } else { _ = os.Unsetenv("DTVEM_ROOT") } - // Reset defaultPaths so it reinitializes - defaultPaths = nil + // Reset paths so it reinitializes + resetPathsForTesting() }() // Set custom DTVEM_ROOT customRoot := "/custom/dtvem/path" _ = os.Setenv("DTVEM_ROOT", customRoot) - // Reset defaultPaths to force reinitialization - defaultPaths = nil + // Reset paths to force reinitialization + resetPathsForTesting() // Test that getRootDir respects DTVEM_ROOT result := getRootDir() @@ -232,3 +240,48 @@ func TestLocalConfigPath(t *testing.T) { t.Errorf("LocalConfigPath() = %q, should end with %q", result, RuntimesFileName) } } + +func TestDefaultPaths_ConcurrentAccess(t *testing.T) { + // Reset to ensure clean state + resetPathsForTesting() + defer resetPathsForTesting() + + const goroutines = 100 + var wg sync.WaitGroup + wg.Add(goroutines) + + // Channel to collect results + results := make(chan *Paths, goroutines) + + // Launch multiple goroutines to call DefaultPaths concurrently + for i := 0; i < goroutines; i++ { + go func() { + defer wg.Done() + results <- DefaultPaths() + }() + } + + wg.Wait() + close(results) + + // Collect all results + var first *Paths + for paths := range results { + if first == nil { + first = paths + } else { + // All goroutines should receive the same pointer + if paths != first { + t.Errorf("DefaultPaths() returned different pointers: %p vs %p", first, paths) + } + } + } + + // Verify the paths are valid + if first == nil { + t.Fatal("DefaultPaths() returned nil") + } + if first.Root == "" { + t.Error("Root path is empty") + } +}