diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-16 03:14:46 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-16 03:14:46 +0200 |
| commit | a71136dc8c2a51dcaa49e4af5e42b3c6bffd6fa0 (patch) | |
| tree | ed05684846c4178a0003d0324645ea8dc49978d4 /internal/logging | |
| parent | 1fc1611fa99993cab5dc8bf0844183285296e3b2 (diff) | |
Fix data races in logging package using atomic operations
Replace bare package-level vars with atomic.Pointer[log.Logger] for std
and atomic.Int32 for logPreviewLimit to prevent concurrent access races.
Add comprehensive tests including concurrency, edge cases, and nil logger.
Coverage: 100%.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/logging')
| -rw-r--r-- | internal/logging/logging.go | 37 | ||||
| -rw-r--r-- | internal/logging/logging_test.go | 70 |
2 files changed, 92 insertions, 15 deletions
diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 259ad68..9d84ea3 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -1,9 +1,11 @@ // Summary: ANSI-styled logging utilities with a bound standard logger and configurable preview truncation. +// All package-level state is accessed via atomic types to avoid data races under concurrent use. package logging import ( "fmt" "log" + "sync/atomic" ) // ANSI color utilities shared across Hexai. @@ -20,35 +22,40 @@ const ( // AnsiBase is the default style: black background + grey foreground. const AnsiBase = AnsiBgBlack + AnsiGrey -// singleton logger used across the codebase -var std *log.Logger +// std is the singleton logger used across the codebase, stored atomically for concurrent safety. +var std atomic.Pointer[log.Logger] -// Bind sets the underlying standard logger to use for Logf. -func Bind(l *log.Logger) { std = l } +// Bind atomically sets the underlying standard logger to use for Logf. +func Bind(l *log.Logger) { std.Store(l) } // Logf prints a formatted message with a module prefix and base ANSI style. +// It atomically loads the bound logger; if none is set the call is a no-op. func Logf(prefix, format string, args ...any) { - if std == nil { + l := std.Load() + if l == nil { return } msg := fmt.Sprintf(format, args...) - std.Print(AnsiBase + prefix + msg + AnsiReset) + l.Print(AnsiBase + prefix + msg + AnsiReset) } -// Logging configuration for previews (shared) -var logPreviewLimit int // 0 means unlimited +// logPreviewLimit is the max characters logged for request/response previews. +// Stored as atomic.Int32 for concurrent safety; 0 means unlimited. +var logPreviewLimit atomic.Int32 -// SetLogPreviewLimit sets the maximum number of characters to log for -// request/response previews. Set to 0 for unlimited. -func SetLogPreviewLimit(n int) { logPreviewLimit = n } +// SetLogPreviewLimit atomically sets the maximum number of characters to log +// for request/response previews. Set to 0 for unlimited. +func SetLogPreviewLimit(n int) { logPreviewLimit.Store(int32(n)) } -// PreviewForLog returns the string truncated to the configured preview limit. +// PreviewForLog returns the string truncated to the configured preview limit +// (loaded atomically). func PreviewForLog(s string) string { - if logPreviewLimit > 0 { - if len(s) <= logPreviewLimit { + limit := int(logPreviewLimit.Load()) + if limit > 0 { + if len(s) <= limit { return s } - return s[:logPreviewLimit] + "…" + return s[:limit] + "…" } return s } diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go index 31603f0..87eb096 100644 --- a/internal/logging/logging_test.go +++ b/internal/logging/logging_test.go @@ -3,6 +3,7 @@ package logging import ( "bytes" "log" + "sync" "testing" ) @@ -21,3 +22,72 @@ func TestPreviewAndLogfAndChatLogger(t *testing.T) { t.Fatalf("expected logged content, got %q", out) } } + +// TestPreviewForLog_ShortString verifies no truncation when string fits within the limit. +func TestPreviewForLog_ShortString(t *testing.T) { + SetLogPreviewLimit(10) + if got := PreviewForLog("abc"); got != "abc" { + t.Fatalf("expected no truncation, got %q", got) + } +} + +// TestPreviewForLog_ExactLimit verifies no truncation when string length equals the limit. +func TestPreviewForLog_ExactLimit(t *testing.T) { + SetLogPreviewLimit(3) + if got := PreviewForLog("abc"); got != "abc" { + t.Fatalf("expected no truncation for exact limit, got %q", got) + } +} + +// TestPreviewForLog_ZeroLimit verifies unlimited preview when limit is 0. +func TestPreviewForLog_ZeroLimit(t *testing.T) { + SetLogPreviewLimit(0) + long := "this is a long string that should not be truncated" + if got := PreviewForLog(long); got != long { + t.Fatalf("expected full string with limit=0, got %q", got) + } +} + +// TestPreviewForLog_NegativeLimit verifies negative limit behaves like unlimited. +func TestPreviewForLog_NegativeLimit(t *testing.T) { + SetLogPreviewLimit(-5) + s := "should not truncate" + if got := PreviewForLog(s); got != s { + t.Fatalf("expected full string with negative limit, got %q", got) + } +} + +// TestPreviewForLog_EmptyString verifies empty input returns empty output. +func TestPreviewForLog_EmptyString(t *testing.T) { + SetLogPreviewLimit(10) + if got := PreviewForLog(""); got != "" { + t.Fatalf("expected empty string, got %q", got) + } +} + +// TestLogf_NilLogger verifies Logf does not panic when no logger is bound. +func TestLogf_NilLogger(t *testing.T) { + // Store a nil logger to test the nil-check path + std.Store(nil) + // Should not panic + Logf("test ", "should be a no-op %s", "value") +} + +// TestConcurrentAccess verifies atomic operations prevent data races +// under concurrent usage of Bind, Logf, SetLogPreviewLimit, and PreviewForLog. +func TestConcurrentAccess(t *testing.T) { + var buf bytes.Buffer + Bind(log.New(&buf, "", 0)) + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + SetLogPreviewLimit(n + 1) + _ = PreviewForLog("concurrent access test string") + Logf("goroutine ", "n=%d", n) + }(i) + } + wg.Wait() +} |
