Test / test (push) Canceled after 0s
Test / fuzz (./compat, FuzzParseFormat) (push) Canceled after 0s
Test / fuzz (./formatter, FuzzFormatJSON) (push) Canceled after 0s
Test / fuzz (./formatter, FuzzFormatTextPolicy) (push) Canceled after 0s
Test / fuzz (./sanitizer, FuzzSanitize) (push) Canceled after 0s
Test / fuzz (./sanitizer, FuzzSerializerJSON) (push) Canceled after 0s
337 lines
12 KiB
Go
337 lines
12 KiB
Go
package log
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestConfigOwnershipAndRollback(t *testing.T) {
|
|
l, dir := newTestLogger(t)
|
|
cfg := l.GetConfig()
|
|
cfg.Format = "json"
|
|
mustNoErr(t, l.ApplyConfig(cfg), "apply snapshot")
|
|
cfg.Format = "raw"
|
|
equal(t, l.GetConfig().Format, "json", "caller config is not retained")
|
|
old := *l.GetConfig()
|
|
blocker := filepath.Join(dir, "blocker")
|
|
mustNoErr(t, os.WriteFile(blocker, nil, 0600), "create blocker")
|
|
mustNoErr(t, os.Mkdir(filepath.Join(dir, "directory"), 0700), "create directory blocker")
|
|
for _, path := range []string{filepath.Join(blocker, "child"), dir} {
|
|
bad := l.GetConfig()
|
|
bad.Directory = path
|
|
bad.Name = "directory"
|
|
bad.Extension = ""
|
|
bad.Format = "txt"
|
|
bad.Level = LevelError
|
|
mustErr(t, l.ApplyConfig(bad), "failed reconfiguration")
|
|
equal(t, *l.GetConfig(), old, "configuration rolled back")
|
|
isTrue(t, l.Enabled(LevelInfo), "failed config did not close emit gate")
|
|
}
|
|
l.Info("survives")
|
|
mustNoErr(t, l.Flush(time.Second), "flush old configuration")
|
|
contains(t, readLog(t, dir), `"fields":["survives"]`, "formatter rolled back")
|
|
}
|
|
|
|
func TestConfigBounds(t *testing.T) {
|
|
for name, mutate := range map[string]func(*Config){
|
|
"name traversal": func(c *Config) { c.Name = "../outside" },
|
|
"name dot": func(c *Config) { c.Name = ".." },
|
|
"extension traversal": func(c *Config) { c.Extension = "../log" },
|
|
"NUL": func(c *Config) { c.Name = "bad\x00name" },
|
|
"buffer": func(c *Config) { c.BufferSize = math.MaxInt64 },
|
|
"size": func(c *Config) { c.MaxSizeKB = math.MaxInt64 },
|
|
"flush": func(c *Config) { c.FlushIntervalMs = math.MaxInt64 },
|
|
"heartbeat": func(c *Config) { c.HeartbeatIntervalS = math.MaxInt64 },
|
|
"retention nan": func(c *Config) { c.RetentionPeriodHrs = math.NaN() },
|
|
"retention infinity": func(c *Config) { c.RetentionCheckMins = math.Inf(1) },
|
|
"retention overflow": func(c *Config) { c.RetentionPeriodHrs = 1e30 },
|
|
"retention underflow": func(c *Config) { c.RetentionCheckMins = 1e-30 },
|
|
} {
|
|
t.Run(name, func(t *testing.T) { c := DefaultConfig(); mutate(c); mustErr(t, c.Validate(), "validate") })
|
|
}
|
|
var nilConfig *Config
|
|
mustErr(t, nilConfig.Validate(), "nil config")
|
|
for _, b := range []*Builder{NewBuilder().MaxSizeMB(math.MaxInt64), NewBuilder().MaxTotalSizeMB(math.MaxInt64), NewBuilder().MinDiskFreeMB(math.MaxInt64)} {
|
|
_, err := b.Build()
|
|
mustErr(t, err, "MB overflow")
|
|
}
|
|
l := NewLogger()
|
|
isFalse(t, l.Enabled(math.MaxInt64), "closed level gate")
|
|
}
|
|
|
|
func TestConcurrentOverridesMerge(t *testing.T) {
|
|
l, _ := newTestLogger(t)
|
|
overrides := []string{"trace_depth=2", "level=debug", "format=json", "show_level=false", "show_timestamp=false", "sanitization=txt"}
|
|
var wg sync.WaitGroup
|
|
start := make(chan struct{})
|
|
for _, override := range overrides {
|
|
wg.Go(func() { <-start; noErr(t, l.ApplyConfigString(override), override) })
|
|
}
|
|
close(start)
|
|
wg.Wait()
|
|
c := l.GetConfig()
|
|
equal(t, c.TraceDepth, int64(2), "depth")
|
|
equal(t, c.Level, LevelDebug, "level")
|
|
equal(t, c.Format, "json", "format")
|
|
isFalse(t, c.ShowLevel, "show level")
|
|
isFalse(t, c.ShowTimestamp, "show timestamp")
|
|
equal(t, c.Sanitization, PolicyTxt, "sanitization")
|
|
}
|
|
|
|
func TestStartDoesNotReplaceProcessor(t *testing.T) {
|
|
l := NewLogger()
|
|
c := DefaultConfig()
|
|
c.EnableConsole = false
|
|
mustNoErr(t, l.ApplyConfig(c), "apply")
|
|
var launches atomic.Int64
|
|
l.SetSpawn(func(fn func()) { launches.Add(1); go fn() })
|
|
var wg sync.WaitGroup
|
|
for range 64 {
|
|
wg.Go(func() { noErr(t, l.Start(), "concurrent start") })
|
|
}
|
|
wg.Wait()
|
|
equal(t, launches.Load(), int64(1), "only one processor")
|
|
mustNoErr(t, l.Shutdown(time.Second), "shutdown")
|
|
mustNoErr(t, l.ApplyConfig(c), "reset after shutdown")
|
|
mustNoErr(t, l.Start(), "start reset logger")
|
|
isTrue(t, l.Enabled(LevelInfo), "reset reopens gate")
|
|
mustNoErr(t, l.Shutdown(time.Second), "shutdown reset logger")
|
|
}
|
|
|
|
type blockedWriter struct {
|
|
entered, release chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (w *blockedWriter) Write(p []byte) (int, error) {
|
|
w.once.Do(func() { close(w.entered) })
|
|
<-w.release
|
|
return len(p), nil
|
|
}
|
|
|
|
func TestTimedOutShutdownKeepsProcessorResources(t *testing.T) {
|
|
l := NewLogger()
|
|
c := DefaultConfig()
|
|
c.Directory = t.TempDir()
|
|
c.EnableFile = true
|
|
c.EnableConsole = true
|
|
mustNoErr(t, l.ApplyConfig(c), "apply")
|
|
w := &blockedWriter{entered: make(chan struct{}), release: make(chan struct{})}
|
|
l.state.StdoutWriter.Store(&sink{w: w})
|
|
mustNoErr(t, l.Start(), "start")
|
|
l.Info("accepted before timeout")
|
|
select {
|
|
case <-w.entered:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("processor never reached writer")
|
|
}
|
|
// Always release the writer even when an assertion fails.
|
|
defer func() { close(w.release); noErr(t, l.Shutdown(time.Second), "retry shutdown") }()
|
|
errContains(t, l.Shutdown(time.Millisecond), "timeout", "shutdown timeout")
|
|
f := l.state.CurrentFile.Load().(*os.File)
|
|
_, err := f.Stat()
|
|
mustNoErr(t, err, "file still owned by draining processor")
|
|
mustErr(t, l.Start(), "restart blocked")
|
|
mustErr(t, l.ApplyConfig(c), "resource replacement blocked")
|
|
}
|
|
|
|
func TestAsyncArgumentSnapshot(t *testing.T) {
|
|
l := NewLogger()
|
|
c := DefaultConfig()
|
|
c.EnableConsole = false
|
|
c.EnableFile = true
|
|
c.Directory = t.TempDir()
|
|
c.Format = "json"
|
|
mustNoErr(t, l.ApplyConfig(c), "apply")
|
|
var run func()
|
|
l.SetSpawn(func(fn func()) { run = fn })
|
|
mustNoErr(t, l.Start(), "start deferred processor")
|
|
args := []any{"original"}
|
|
l.Info(args...)
|
|
args[0] = "mutated"
|
|
fields := map[string]any{"value": "original"}
|
|
l.LogStructured(LevelInfo, "message", fields)
|
|
fields["value"] = "mutated"
|
|
go run()
|
|
mustNoErr(t, l.Shutdown(time.Second), "shutdown")
|
|
data := readLog(t, c.Directory)
|
|
notContains(t, data, "mutated", "snapshotted arguments and map")
|
|
equal(t, strings.Count(data, "original"), 2, "original values")
|
|
}
|
|
|
|
func TestFlushReportsSyncError(t *testing.T) {
|
|
l, _ := newTestLogger(t)
|
|
mustNoErr(t, l.Flush(time.Second), "initial flush")
|
|
file := l.state.CurrentFile.Load().(*os.File)
|
|
mustNoErr(t, file.Close(), "inject closed file")
|
|
err := l.Flush(time.Second)
|
|
if !errors.Is(err, os.ErrClosed) {
|
|
t.Fatalf("expected sync error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConsoleOnlyRetentionIsInert(t *testing.T) {
|
|
c := DefaultConfig()
|
|
c.EnableConsole = false
|
|
c.Directory = t.TempDir()
|
|
c.RetentionPeriodHrs = 1
|
|
name := filepath.Join(c.Directory, "old.log")
|
|
mustNoErr(t, os.WriteFile(name, []byte("keep"), 0600), "write")
|
|
old := time.Now().Add(-2 * time.Hour)
|
|
mustNoErr(t, os.Chtimes(name, old, old), "set age")
|
|
l := NewLogger()
|
|
mustNoErr(t, l.ApplyConfig(c), "apply")
|
|
l.state.EarliestFileTime.Store(old)
|
|
l.handleRetentionCheck()
|
|
mustNoErr(t, l.cleanExpiredLogs(old), "explicit cleanup")
|
|
data, err := os.ReadFile(name)
|
|
mustNoErr(t, err, "file survived")
|
|
equal(t, string(data), "keep", "contents")
|
|
timers := l.setupProcessingTimers()
|
|
defer l.stopProcessingTimers(timers)
|
|
if timers.retentionChan != nil {
|
|
t.Fatal("console-only retention timer enabled")
|
|
}
|
|
}
|
|
|
|
func TestArchiveCollisionBeyondOneThousand(t *testing.T) {
|
|
l, dir := newTestLogger(t)
|
|
stamp := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
base := "log_" + stamp.Format("060102_150405")
|
|
mustNoErr(t, os.Mkdir(filepath.Join(dir, base+".log"), 0700), "directory collision")
|
|
for i := 1; i <= 1000; i++ {
|
|
mustNoErr(t, os.WriteFile(filepath.Join(dir, base+"_"+itoa(i)+".log"), nil, 0600), "archive")
|
|
}
|
|
equal(t, l.generateArchiveLogFileName(stamp), base+"_1001.log", "no capped collision reuse")
|
|
}
|
|
|
|
func TestExtensionlessAccountingAndCleanup(t *testing.T) {
|
|
l := NewLogger()
|
|
c := DefaultConfig()
|
|
c.EnableConsole = false
|
|
c.EnableFile = true
|
|
c.Directory = t.TempDir()
|
|
c.Extension = ""
|
|
mustNoErr(t, l.ApplyConfig(c), "apply")
|
|
defer l.Shutdown()
|
|
for _, name := range []string{"old", "other.txt"} {
|
|
mustNoErr(t, os.WriteFile(filepath.Join(c.Directory, name), []byte("123"), 0600), "write")
|
|
}
|
|
size, err := l.getLogDirSize(c.Directory, "")
|
|
mustNoErr(t, err, "size")
|
|
equal(t, size, int64(3), "extensionless size")
|
|
count, err := l.getLogFileCount(c.Directory, "")
|
|
mustNoErr(t, err, "count")
|
|
equal(t, count, 2, "extensionless count")
|
|
mustNoErr(t, l.cleanOldLogs(0), "zero demand does not remove archives")
|
|
mustNoErr(t, l.cleanOldLogs(3), "cleanup")
|
|
_, err = os.Stat(filepath.Join(c.Directory, "other.txt"))
|
|
mustNoErr(t, err, "other extension preserved")
|
|
}
|
|
|
|
func TestTextSanitizesMetadata(t *testing.T) {
|
|
var out bytes.Buffer
|
|
l := NewLogger()
|
|
c := DefaultConfig()
|
|
c.Format = "txt"
|
|
c.Sanitization = PolicyTxt
|
|
c.TimestampFormat = "bad\n\x1b"
|
|
c.EnableConsole = true
|
|
mustNoErr(t, l.ApplyConfig(c), "apply")
|
|
l.SetContextKeys("key\n\x1b")
|
|
l.state.StdoutWriter.Store(&sink{w: &out})
|
|
mustNoErr(t, l.Start(), "start")
|
|
l.LogContext(Context{Tag: "value"}, FlagKV, LevelInfo, 0, "key\n\x1b", "value")
|
|
mustNoErr(t, l.Shutdown(time.Second), "shutdown")
|
|
equal(t, bytes.Count(out.Bytes(), []byte{'\n'}), 1, "one record line")
|
|
notContains(t, out.String(), "\x1b", "no terminal escape")
|
|
}
|
|
|
|
type shortWriter struct{}
|
|
|
|
func (shortWriter) Write(p []byte) (int, error) { return len(p) - 1, nil }
|
|
|
|
func TestConsoleWriteFailureAccounting(t *testing.T) {
|
|
l := NewLogger()
|
|
c := DefaultConfig()
|
|
mustNoErr(t, l.ApplyConfig(c), "apply")
|
|
l.state.StdoutWriter.Store(&sink{w: shortWriter{}})
|
|
mustNoErr(t, l.Start(), "start")
|
|
l.Info("short write")
|
|
mustNoErr(t, l.Shutdown(time.Second), "shutdown")
|
|
equal(t, l.state.TotalDroppedLogs.Load(), uint64(1), "console drop")
|
|
equal(t, l.state.DroppedLogs.Load(), uint64(1), "interval drop")
|
|
equal(t, l.state.TotalLogsProcessed.Load(), uint64(0), "not processed successfully")
|
|
}
|
|
|
|
func TestDrainFlushIsBounded(t *testing.T) {
|
|
// Refill after every write, reproducing a queue which never becomes empty.
|
|
// A snapshot drain must still complete after the original pending count.
|
|
l := NewLogger()
|
|
c := DefaultConfig()
|
|
c.Format = "raw"
|
|
mustNoErr(t, l.ApplyConfig(c), "apply")
|
|
ch := make(chan logRecord, 4)
|
|
for range cap(ch) {
|
|
ch <- logRecord{Args: []any{"x"}}
|
|
}
|
|
l.state.StdoutWriter.Store(&sink{w: writerFunc(func(p []byte) (int, error) { ch <- logRecord{Args: []any{"later"}}; return len(p), nil })})
|
|
done := make(chan struct{})
|
|
go func() { l.drain(ch); close(done) }()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("drain starved on refilled queue")
|
|
}
|
|
equal(t, l.state.TotalLogsProcessed.Load(), uint64(4), "bounded work")
|
|
equal(t, len(ch), 4, "later records left for next iteration")
|
|
}
|
|
|
|
type writerFunc func([]byte) (int, error)
|
|
|
|
func (fn writerFunc) Write(p []byte) (int, error) { return fn(p) }
|
|
|
|
func TestReconfigureDrainsOldFileAndFormat(t *testing.T) {
|
|
l := NewLogger()
|
|
c := DefaultConfig()
|
|
c.Directory = t.TempDir()
|
|
c.EnableFile = true
|
|
c.EnableConsole = true
|
|
c.Format = "raw"
|
|
c.MaxSizeKB = 1
|
|
c.FlushIntervalMs = 1000
|
|
mustNoErr(t, l.ApplyConfig(c), "apply")
|
|
w := &blockedWriter{entered: make(chan struct{}), release: make(chan struct{})}
|
|
l.state.StdoutWriter.Store(&sink{w: w})
|
|
var diagnostics []string
|
|
l.SetErrorHandler(func(msg string) { diagnostics = append(diagnostics, msg) })
|
|
mustNoErr(t, l.Start(), "start")
|
|
payload := strings.Repeat("x", 1500)
|
|
l.Info(payload)
|
|
<-w.entered
|
|
l.Info("old-format-tail")
|
|
updated := l.GetConfig()
|
|
updated.EnableFile = false
|
|
updated.EnableConsole = false
|
|
updated.Format = "json"
|
|
done := make(chan error, 1)
|
|
go func() { done <- l.ApplyConfig(updated) }()
|
|
mustEventually(t, time.Second, "stop began", func() bool { return !l.state.Started.Load() })
|
|
close(w.release)
|
|
mustNoErr(t, <-done, "reconfigure")
|
|
mustNoErr(t, l.Shutdown(time.Second), "shutdown")
|
|
data := readAllLogs(t, c.Directory)
|
|
contains(t, data, payload, "original payload")
|
|
contains(t, data, "old-format-tail", "queued record used old format")
|
|
equal(t, len(data), len(payload)+len("old-format-tail"), "exact drained bytes")
|
|
equal(t, len(diagnostics), 0, "closed current handle after draining rotation")
|
|
}
|