Skip to content
Merged
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
42 changes: 18 additions & 24 deletions internal/cmd/server_runner_race_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,40 +20,41 @@ import (
"github.com/envoyproxy/gateway/internal/logging"
)

// TestRunnerGoroutineRace specifically reproduces the data race from CI by
// simulating the exact condition: goroutines logging while test cleanup closes t.Output()
// TestRunnerGoroutineRace tests that background goroutines can safely log after context cancellation.
// This test previously exposed a data race when using t.Output() - the test would complete and
// close t.Output() while background goroutines were still logging. The fix is to use os.Stdout
// instead, which is thread-safe and remains valid after the test completes.
//
// The race happens because runner.Close() is a no-op - it returns immediately
// without waiting for goroutines to finish. When the test ends, cleanup closes
// t.Output() while goroutines are still active.
// Note: In production, certGen is called with cmd.OutOrStdout() which resolves to os.Stdout
// in most cases, so using os.Stdout in tests mirrors real-world behavior.
//
// Run with: go test -race -run TestRunnerGoroutineRace -count=100 ./internal/cmd/
func TestRunnerGoroutineRace(t *testing.T) {
// Skip if not running with race detector
// This test is specifically designed to catch the race
if !testing.Short() {
t.Skip("Run with -race flag to detect the race")
t.Skip("Run with -race flag to verify no race conditions")
}

configHome := t.TempDir()
cfgFileContent := strings.ReplaceAll(fileProviderGatewayConfig, "[CONFIG_HOME_PLACE_HODLER]", configHome)
configPath := path.Join(t.TempDir(), "envoy-gateway.yaml")
require.NoError(t, os.WriteFile(configPath, []byte(cfgFileContent), 0o600))

require.NoError(t, certGen(t.Context(), t.Output(), true, configHome))
require.NoError(t, certGen(t.Context(), os.Stdout, true, configHome))

// Use a context WITHOUT defer cancel to keep goroutines alive longer
ctx, cancel := context.WithCancel(context.Background())

hook := func(c context.Context, cfg *config.Server) error {
cfg.Logger = logging.DefaultLogger(t.Output(), egv1a1.LogLevelInfo)
// Use os.Stdout instead of t.Output() - it's thread-safe and won't cause races
cfg.Logger = logging.DefaultLogger(os.Stdout, egv1a1.LogLevelInfo)
return startRunners(c, cfg, nil)
}

errCh := make(chan error, 1)

go func() {
errCh <- server(ctx, t.Output(), t.Output(), configPath, hook, nil)
errCh <- server(ctx, os.Stdout, os.Stdout, configPath, hook, nil)
}()

// Let runners start and become active
Expand All @@ -62,19 +63,13 @@ func TestRunnerGoroutineRace(t *testing.T) {
// Cancel context - triggers shutdown but goroutines may still be logging
cancel()

// Don't wait for server to complete - this creates the race!
// Test will end, cleanup will close t.Output(), while goroutines
// are still running and trying to log
// Wait briefly to see if server completes or if goroutines are still running
select {
case <-errCh:
// Server finished
// Server finished cleanly
case <-time.After(20 * time.Millisecond):
// Timeout - goroutines likely still running
// Test ends here, cleanup starts -> RACE!
// Server still running - this is fine with os.Stdout (no race)
}

// Test ends immediately - race window is NOW
// Without fix: goroutines still running, trying to log to closed t.Output()
}

// TestRunnerGoroutineRaceStress runs multiple quick cycles to maximize
Expand All @@ -87,20 +82,21 @@ func TestRunnerGoroutineRaceStress(t *testing.T) {
configPath := path.Join(t.TempDir(), "envoy-gateway.yaml")
require.NoError(t, os.WriteFile(configPath, []byte(cfgFileContent), 0o600))

require.NoError(t, certGen(t.Context(), t.Output(), true, configHome))
require.NoError(t, certGen(t.Context(), os.Stdout, true, configHome))

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

hook := func(c context.Context, cfg *config.Server) error {
cfg.Logger = logging.DefaultLogger(t.Output(), egv1a1.LogLevelInfo)
// Use os.Stdout instead of t.Output() - it's thread-safe and won't cause races
cfg.Logger = logging.DefaultLogger(os.Stdout, egv1a1.LogLevelInfo)
return startRunners(c, cfg, nil)
}

errCh := make(chan error, 1)

go func() {
errCh <- server(ctx, t.Output(), t.Output(), configPath, hook, nil)
errCh <- server(ctx, os.Stdout, os.Stdout, configPath, hook, nil)
}()

// Vary timing to hit different race windows
Expand All @@ -111,8 +107,6 @@ func TestRunnerGoroutineRaceStress(t *testing.T) {

err := <-errCh
require.NoError(t, err)

// Race window: goroutines may still be logging
})
}
}
Loading