fix(logging): make level and field-name access concurrency-safe

SetFieldName wrote a package-global map with no lock while log read it
from many goroutines, and the level, caller, and time-format setters
mutated fields without synchronisation. A runtime setter call could
crash the process with a concurrent map write.

The scalar fields now use atomics on the hot log path and the global
field-name map uses a read-write mutex. Behaviour for init-time
configuration is unchanged.
This commit is contained in:
2026-08-14 11:10:01 +01:00
parent 159faa68e0
commit a774f9905b
3 changed files with 133 additions and 20 deletions
+85
View File
@@ -2,8 +2,12 @@ package libpack_logger
import (
"bytes"
"strings"
"sync"
"testing"
"time"
"github.com/goccy/go-json"
)
// Test_LogConcurrentAccess verifies that the logger correctly handles concurrent access
@@ -52,3 +56,84 @@ func TestLogConcurrentAccess(t *testing.T) {
t.Error("Expected log output, but got none")
}
}
// TestConcurrentSettersDuringLog verifies that SetFieldName, SetMinLogLevel,
// SetShowCaller, and SetTimeFormat can run concurrently with active log()
// calls without a data race.
//
// Before the fix, SetFieldName wrote the package-global fieldNames map with
// no lock while log() read it from many goroutines, and
// SetMinLogLevel/SetShowCaller/SetTimeFormat mutated Logger fields with no
// lock while shouldLog()/log() read them. This test fails under
// `go test -race` on that code and passes once the setters and readers are
// synchronized.
func TestConcurrentSettersDuringLog(t *testing.T) {
// fieldNames is package-global, so snapshot and restore it: this test
// mutates it concurrently and must not leak state into other tests.
originalFieldNames := make(map[string]string)
for k, v := range fieldNames {
originalFieldNames[k] = v
}
defer func() {
for k, v := range originalFieldNames {
fieldNames[k] = v
}
}()
output := &bytes.Buffer{}
logger := New().SetOutput(output).SetMinLogLevel(LEVEL_DEBUG)
const (
numLoggers = 50
numSetters = 50
iterations = 200
)
var wg sync.WaitGroup
wg.Add(numLoggers + numSetters)
// Goroutines that continuously log, exercising log()'s reads of the
// global fieldNames map and the Logger's minLogLevel/showCaller/
// timeFormat fields.
for i := 0; i < numLoggers; i++ {
go func(id int) {
defer wg.Done()
msg := &LogMessage{Message: "concurrent setter test"}
for j := 0; j < iterations; j++ {
logger.Info(msg)
logger.Debug(msg)
}
}(i)
}
// Goroutines that continuously mutate logger-wide configuration while
// logging is in flight from the goroutines above.
for i := 0; i < numSetters; i++ {
go func(id int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
logger.SetFieldName("timestamp", "ts")
logger.SetMinLogLevel(LEVEL_DEBUG)
logger.SetShowCaller(id%2 == 0)
logger.SetTimeFormat(time.RFC3339Nano)
}
}(i)
}
wg.Wait()
if output.Len() == 0 {
t.Fatal("Expected log output, but got none")
}
// Each emitted line must still be well-formed JSON: the concurrent
// setter calls must not corrupt log() output, only race on which
// setting "wins" at each point in time.
lines := strings.Split(strings.TrimSpace(output.String()), "\n")
for i, line := range lines {
var decoded map[string]any
if err := json.Unmarshal([]byte(line), &decoded); err != nil {
t.Fatalf("line %d is not valid JSON: %v\nline: %s", i, err, line)
}
}
}