Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<details>" >> $GITHUB_STEP_SUMMARY
echo "<summary>Full Test Output</summary>" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
cat race-test-output.log >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "</details>" >> $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:
Expand Down
13 changes: 9 additions & 4 deletions src/internal/config/paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"runtime"
"sync"

"github.com/dtvem/dtvem/src/internal/constants"
)
Expand All @@ -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
}

Expand Down
61 changes: 57 additions & 4 deletions src/internal/config/paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)

Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand Down Expand Up @@ -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")
}
}