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
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/charmbracelet/huh v0.7.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/go-viper/mapstructure/v2 v2.4.0
github.com/gofrs/flock v0.13.0
github.com/google/uuid v1.6.0
github.com/mattn/go-colorable v0.1.14
github.com/mattn/go-runewidth v0.0.19
Expand All @@ -22,7 +23,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
go.uber.org/mock v0.6.0
golang.org/x/sys v0.36.0
golang.org/x/sys v0.37.0
golang.org/x/term v0.35.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.39.0
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
Expand Down Expand Up @@ -170,8 +172,8 @@ golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"time"

"github.com/go-viper/mapstructure/v2"
"github.com/gofrs/flock"
"github.com/spf13/pflag"
"github.com/spf13/viper"

Expand Down Expand Up @@ -68,6 +69,12 @@ func New(dataDir string) (*Config, cenclierrors.CencliError) {

configPath := filepath.Join(dataDir, "config.yaml")

fileLock := flock.New(configPath + ".lock")
if err := fileLock.Lock(); err != nil {
Comment thread
Zackaryia marked this conversation as resolved.
return nil, newInvalidConfigError(fmt.Errorf("failed to acquire config lock: %w", err).Error())
}
defer func() { _ = fileLock.Unlock() }()
Comment thread
btschwartz12 marked this conversation as resolved.

if err := viper.ReadInConfig(); err != nil {
var configFileNotFoundError viper.ConfigFileNotFoundError
if !errors.As(err, &configFileNotFoundError) {
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config_test.go
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.

This change is because the file lock fails before writing fails, so we need to edit the error message. Permission denied makes more sense in context.

Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ func TestConfigWriteErrors(t *testing.T) {
_, err = New(tempDir)
var invalidConfigErr InvalidConfigError
assert.ErrorAs(t, err, &invalidConfigErr)
assert.Contains(t, err.Error(), "failed to write config file")
assert.Contains(t, err.Error(), "failed to acquire config lock")
})
}

Expand Down
184 changes: 184 additions & 0 deletions internal/config/race_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package config

import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"

"gopkg.in/yaml.v3"
)

// TestRaceConditionEndToEnd reproduces the real file-level race condition that
// occurs when multiple cencli processes initialize config concurrently against
// the same data directory. Each subprocess is a real OS process with its own
// viper instance — just like production. The race is in the non-atomic
// read-modify-write of config.yaml (viper.WriteConfig + addDocCommentsToYAML).
//
// Run with: go test -run TestRaceConditionEndToEnd -count=1 -v ./internal/config/
func TestRaceConditionEndToEnd(t *testing.T) {
const processes = 15

dataDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dataDir, "templates"), 0o755); err != nil {
t.Fatal(err)
}

configPath := filepath.Join(dataDir, "config.yaml")

type procResult struct {
id int
exitCode int
output string
err error
}

var (
wg sync.WaitGroup
mu sync.Mutex
results []procResult
)

// All processes start as close together as possible.
start := make(chan struct{})

for i := 0; i < processes; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
<-start

cmd := exec.Command(
os.Args[0],
"-test.run=^TestRaceWorker$",
"-test.v",
)
cmd.Env = append(os.Environ(),
"RACE_WORKER=1",
fmt.Sprintf("RACE_DATA_DIR=%s", dataDir),
)

out, err := cmd.CombinedOutput()

exitCode := 0
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
exitCode = ee.ExitCode()
} else {
exitCode = -1
}
}

mu.Lock()
results = append(results, procResult{
id: id,
exitCode: exitCode,
output: string(out),
err: err,
})
mu.Unlock()
}(i)
}

close(start)
wg.Wait()

// Tally process-level failures.
var processErrors int
for _, r := range results {
if r.exitCode != 0 {
processErrors++
t.Logf("process %d exited %d:\n%s", r.id, r.exitCode, r.output)
}
}

// Check the final state of config.yaml — the file all processes raced on.
finalRaw, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("cannot read final config.yaml: %v", err)
}

var (
fileEmpty bool
fileCorrupt bool
yamlErr string
)

if len(finalRaw) == 0 {
fileEmpty = true
} else {
var parsed map[string]interface{}
if err := yaml.Unmarshal(finalRaw, &parsed); err != nil {
fileCorrupt = true
yamlErr = err.Error()
}
}

t.Logf("--- Race Condition Results ---")
t.Logf(" Processes launched: %d", processes)
t.Logf(" Process failures: %d", processErrors)
t.Logf(" Final file empty: %v", fileEmpty)
t.Logf(" Final file corrupt: %v", fileCorrupt)
if fileCorrupt {
t.Logf(" YAML error: %s", yamlErr)
t.Logf(" File content:\n%s", finalRaw)
}

if processErrors > 0 || fileEmpty || fileCorrupt {
t.Errorf("Race condition reproduced: processes_failed=%d file_empty=%v file_corrupt=%v\n"+
"Multiple processes doing read-modify-write on config.yaml without file locking\n"+
"causes corruption visible to concurrent or subsequent CLI invocations.",
processErrors, fileEmpty, fileCorrupt)
}
}

// TestRaceWorker verifies that config.New() produces a valid, non-corrupt
// config file. When spawned as a subprocess by TestRaceConditionEndToEnd
// (RACE_WORKER=1, RACE_DATA_DIR set), it operates against the shared data
// directory to exercise the file-lock under contention. When run standalone
// it uses its own temp directory as a basic config.New() smoke test.
func TestRaceWorker(t *testing.T) {
dataDir := os.Getenv("RACE_DATA_DIR")
if dataDir == "" {
dataDir = t.TempDir()
}

cfg, cErr := New(dataDir)
if cErr != nil {
t.Fatalf("New() failed: %v", cErr)
}

// Verify the returned config is sane.
if cfg.OutputFormat == "" {
t.Error("config has empty output-format")
}

// Verify the file on disk is valid YAML right after our write.
configPath := filepath.Join(dataDir, "config.yaml")
raw, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("cannot read config.yaml after New(): %v", err)
}
if len(raw) == 0 {
t.Fatal("config.yaml is empty immediately after New()")
}

var parsed map[string]interface{}
if err := yaml.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("config.yaml is corrupted after New(): %v", err)
}

// Check for partial writes — key fields should be present.
requiredKeys := []string{"output-format", "streaming", "timeouts", "retry-strategy"}
content := string(raw)
for _, key := range requiredKeys {
if !strings.Contains(content, key+":") {
t.Errorf("config.yaml missing expected key %q — possible truncated write", key)
}
}
}
Loading