diff options
Diffstat (limited to 'internal/io/dlog/loggers')
| -rw-r--r-- | internal/io/dlog/loggers/file.go | 122 | ||||
| -rw-r--r-- | internal/io/dlog/loggers/file_test.go | 183 | ||||
| -rw-r--r-- | internal/io/dlog/loggers/fout.go | 68 | ||||
| -rw-r--r-- | internal/io/dlog/loggers/fout_test.go | 199 | ||||
| -rw-r--r-- | internal/io/dlog/loggers/stdout.go | 86 | ||||
| -rw-r--r-- | internal/io/dlog/loggers/stdout_test.go | 190 |
6 files changed, 815 insertions, 33 deletions
diff --git a/internal/io/dlog/loggers/file.go b/internal/io/dlog/loggers/file.go index 8e567bc..b3b6a10 100644 --- a/internal/io/dlog/loggers/file.go +++ b/internal/io/dlog/loggers/file.go @@ -12,6 +12,26 @@ import ( "github.com/mimecast/dtail/internal/config" ) +const ( + // fileWriterBufSize is the size of the bufio buffer wrapping the log file + // descriptor. A real buffer (instead of the old 1-byte writer that forced a + // write syscall per line) lets bulk payload — e.g. dcat/dgrep tee — batch + // into ~one syscall per bufferful, cutting the client receive-path syscall + // count and CPU by ~5x. bufio auto-flushes when full, so high-throughput + // output never stalls in the buffer. + fileWriterBufSize = 64 * 1024 + // fileIdleFlushInterval bounds how long buffered data may sit unwritten when + // output goes idle (follow/interactive mode trickling a few lines). Without + // it, low-volume output would be stuck behind the buffer until it fills or + // the logger shuts down, so follow/tail would appear frozen on disk. + fileIdleFlushInterval = 100 * time.Millisecond + // fileFlushTimeout bounds how long a synchronous Flush() waits for the + // logger goroutine to acknowledge. It exists purely as a deadlock guard for + // the rare case where the goroutine is paused or already gone (e.g. Flush + // racing shutdown); under normal operation the ack is near-instant. + fileFlushTimeout = 2 * time.Second +) + type fileMessageBuf struct { now time.Time message string @@ -23,7 +43,13 @@ type file struct { pauseCh chan struct{} resumeCh chan struct{} rotateCh chan struct{} - flushCh chan struct{} + // flushCh carries a per-call reply channel so Flush() can block until the + // logger goroutine has actually drained the buffer channel and flushed the + // bufio writer to disk. This makes Flush() synchronous, which the crash path + // (dlog.FatalPanic -> Flush -> panic) relies on: an async signal could let + // the process unwind before the goroutine drains, dropping up to one buffer + // (64KB) of Fatal diagnostics. + flushCh chan chan struct{} fd *os.File writer *bufio.Writer mutex sync.Mutex @@ -32,13 +58,20 @@ type file struct { strategy Strategy } +var _ Logger = (*file)(nil) + func newFile(strategy Strategy) *file { + // Pause/Resume/Rotate use capacity-1, non-blocking coalescing sends so + // callers never block on the logger goroutine (repeated signals collapse + // into one pending notification). flushCh is unbuffered and carries a reply + // channel because Flush() is synchronous: it must wait for the goroutine to + // drain and write before returning. return &file{ bufferCh: make(chan *fileMessageBuf, runtime.NumCPU()*100), - pauseCh: make(chan struct{}), - resumeCh: make(chan struct{}), - rotateCh: make(chan struct{}), - flushCh: make(chan struct{}), + pauseCh: make(chan struct{}, 1), + resumeCh: make(chan struct{}, 1), + rotateCh: make(chan struct{}, 1), + flushCh: make(chan chan struct{}), strategy: strategy, } } @@ -67,17 +100,43 @@ func (f *file) Start(ctx context.Context, wg *sync.WaitGroup) { go func() { defer wg.Done() + // Idle-flush ticker: with a real (64KB) buffer, low-volume output + // (follow/interactive) would otherwise sit in the buffer until it + // fills. The ticker flushes any partial buffer promptly so follow/tail + // output reaches disk within fileIdleFlushInterval. flush() is cheap + // when nothing is buffered. + ticker := time.NewTicker(fileIdleFlushInterval) + defer ticker.Stop() for { select { case m := <-f.bufferCh: f.write(m) + case <-ticker.C: + f.flush() case <-f.pauseCh: + // Flush before pausing so all output produced so far is on + // disk before the caller (e.g. an interactive prompt) writes + // directly to the terminal/file; preserves ordering. + f.flush() pause(ctx) - case <-f.flushCh: + case done := <-f.flushCh: + // Synchronous flush: drain + write, then acknowledge so the + // blocked Flush() caller can proceed (used by FatalPanic). f.flush() + close(done) + case <-f.rotateCh: + // Force re-opening the outfile on the next write. + // Drained here (not only from write()) so that Rotate() + // makes progress even when no log messages arrive. + f.lastFileName = "" case <-ctx.Done(): f.flush() - f.fd.Close() + // f.fd is only populated after the first getWriter() call; + // guard against a nil pointer when the logger is shut down + // before anything has been written. + if f.fd != nil { + f.fd.Close() + } return } } @@ -100,21 +159,43 @@ func (f *file) RawWithColors(now time.Time, message, coloredMessage string) { panic("Colors not supported in file logger") } -func (f *file) Pause() { f.pauseCh <- struct{}{} } -func (f *file) Resume() { f.resumeCh <- struct{}{} } -func (f *file) Flush() { f.flushCh <- struct{}{} } +// signal performs a non-blocking, coalescing send on a capacity-1 control +// channel. If a signal is already pending the new one is dropped, which is +// the desired behaviour for idempotent operations such as Pause/Rotate/Flush. +func signal(ch chan struct{}) { + select { + case ch <- struct{}{}: + default: + } +} -func (f *file) Rotate() { f.rotateCh <- struct{}{} } -func (*file) SupportsColors() bool { return false } +func (f *file) Pause() { signal(f.pauseCh) } +func (f *file) Resume() { signal(f.resumeCh) } +func (f *file) Rotate() { signal(f.rotateCh) } -func (f *file) write(m *fileMessageBuf) { +// Flush synchronously drains any queued messages and writes the bufio buffer to +// disk, blocking until the logger goroutine acknowledges. The crash path +// (dlog.FatalPanic) depends on this: with an async signal the process could +// panic and unwind before the goroutine drained, losing buffered diagnostics. +// A bounded timeout guards against a deadlock when the goroutine is paused or +// has already exited (Flush racing shutdown), in which case the ctx.Done path +// has already flushed or will flush. +func (f *file) Flush() { + done := make(chan struct{}) select { - case <-f.rotateCh: - // Force re-opening the outfile next time in getWriter. - f.lastFileName = "" - default: + case f.flushCh <- done: + case <-time.After(fileFlushTimeout): + return + } + select { + case <-done: + case <-time.After(fileFlushTimeout): } +} + +func (*file) SupportsColors() bool { return false } +func (f *file) write(m *fileMessageBuf) { var writer *bufio.Writer if f.strategy.Rotation == DailyRotation { writer = f.getWriter(m.now.Format("20060102")) @@ -150,9 +231,12 @@ func (f *file) getWriter(name string) *bufio.Writer { f.writer.Flush() f.fd.Close() } - // Set new writer. + // Set new writer. Use a real buffer (fileWriterBufSize) so bulk payload + // batches into few write syscalls instead of one-or-two per line. The + // logger goroutine's idle ticker and the ctx.Done/flush/pause paths keep + // low-volume and shutdown output from being stuck in the buffer. f.fd = newFd - f.writer = bufio.NewWriterSize(f.fd, 1) + f.writer = bufio.NewWriterSize(f.fd, fileWriterBufSize) f.lastFileName = name return f.writer diff --git a/internal/io/dlog/loggers/file_test.go b/internal/io/dlog/loggers/file_test.go new file mode 100644 index 0000000..0436327 --- /dev/null +++ b/internal/io/dlog/loggers/file_test.go @@ -0,0 +1,183 @@ +package loggers + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/mimecast/dtail/internal/config" +) + +// withTempLogDir points config.Common.LogDir at a fresh temp dir for the +// duration of a test and restores the previous config afterwards. The file +// logger resolves its output path from config.Common.LogDir at write time. +func withTempLogDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + prev := config.Common + config.Common = &config.CommonConfig{LogDir: dir} + t.Cleanup(func() { config.Common = prev }) + return dir +} + +// startFileLogger starts f and returns a stop func that cancels the context and +// JOINS the logger goroutine (wg.Wait). Tests must defer stop() so the goroutine +// has fully exited before returning: withTempLogDir's t.Cleanup restores the +// global config.Common, and a still-running goroutine reading config.Common.LogDir +// would otherwise race that restore. For the same reason none of these tests may +// call t.Parallel — they mutate the process-global config.Common. +func startFileLogger(t *testing.T, f *file) func() { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + wg.Add(1) + f.Start(ctx, &wg) + return func() { + cancel() + wg.Wait() + } +} + +func readLogFile(t *testing.T, dir, base string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, base+".log")) + if err != nil { + if os.IsNotExist(err) { + return "" + } + t.Fatalf("reading log file: %v", err) + } + return string(data) +} + +// TestFileLoggerNothingLostOnClose verifies every logged line reaches disk when +// the context is cancelled (clean shutdown): the goroutine drains the buffer +// channel and flushes the 64KB writer before closing the fd. +func TestFileLoggerNothingLostOnClose(t *testing.T) { + dir := withTempLogDir(t) + base := "close-test" + f := newFile(Strategy{Rotation: SignalRotation, FileBase: base}) + stop := startFileLogger(t, f) + + const n = 500 + var want strings.Builder + for i := 0; i < n; i++ { + line := "line-" + strconv.Itoa(i) + f.Log(time.Now(), line) + want.WriteString(line + "\n") + } + + // stop() cancels the context and joins the goroutine, which flushes and + // closes the fd on the way out — so all output must be on disk afterwards. + stop() + + if got := readLogFile(t, dir, base); got != want.String() { + t.Fatalf("lost output on close: got %d bytes, want %d bytes", + len(got), want.Len()) + } +} + +// TestFileLoggerIdleFlush verifies a single low-volume line (follow/tail style) +// is not stuck behind the 64KB buffer: the idle ticker flushes it to disk +// promptly without any explicit Flush or shutdown. The logger goroutine is +// joined via stop() before returning so it cannot outlive config.Common. +func TestFileLoggerIdleFlush(t *testing.T) { + dir := withTempLogDir(t) + base := "idle-test" + f := newFile(Strategy{Rotation: SignalRotation, FileBase: base}) + stop := startFileLogger(t, f) + defer stop() + + f.Log(time.Now(), "follow-line") + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(readLogFile(t, dir, base), "follow-line") { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("follow-style line stuck behind buffer; idle flush did not emit it") +} + +// TestFileLoggerExplicitFlush verifies Flush() is SYNCHRONOUS: once it returns, +// the buffered data is already on disk (no polling needed). This is the property +// dlog.FatalPanic relies on to not drop Fatal diagnostics before panicking. +func TestFileLoggerExplicitFlush(t *testing.T) { + dir := withTempLogDir(t) + base := "flush-test" + f := newFile(Strategy{Rotation: SignalRotation, FileBase: base}) + stop := startFileLogger(t, f) + defer stop() + + f.Log(time.Now(), "flush-me") + f.Flush() + + if got := readLogFile(t, dir, base); !strings.Contains(got, "flush-me") { + t.Fatalf("synchronous Flush() did not persist data before returning; got %q", got) + } +} + +// TestFileLoggerRotateDoesNotBlockWithoutWrites verifies that Rotate() does +// not deadlock when no log messages have been produced. Previously rotateCh +// was unbuffered and only drained opportunistically from write(), so a SIGHUP +// before any Log() call would block the caller forever. +func TestFileLoggerRotateDoesNotBlockWithoutWrites(t *testing.T) { + f := newFile(Strategy{Rotation: SignalRotation, FileBase: "unit-test"}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + wg.Add(1) + f.Start(ctx, &wg) + + done := make(chan struct{}) + go func() { + f.Rotate() + close(done) + }() + + select { + case <-done: + case <-time.After(500 * time.Millisecond): + t.Fatal("Rotate() blocked without any writes; expected prompt return") + } + + cancel() + wg.Wait() +} + +// TestFileLoggerCancelBeforeFirstWriteDoesNotPanic verifies that cancelling +// the context before any write has happened does not panic. Previously the +// goroutine called f.fd.Close() unconditionally, but f.fd is only populated +// by the first getWriter() call, so a ctx cancel with no prior writes +// panicked on a nil pointer. +func TestFileLoggerCancelBeforeFirstWriteDoesNotPanic(t *testing.T) { + f := newFile(Strategy{Rotation: SignalRotation, FileBase: "unit-test"}) + + ctx, cancel := context.WithCancel(context.Background()) + + var wg sync.WaitGroup + wg.Add(1) + f.Start(ctx, &wg) + + cancel() + + doneCh := make(chan struct{}) + go func() { + wg.Wait() + close(doneCh) + }() + + select { + case <-doneCh: + case <-time.After(1 * time.Second): + t.Fatal("file logger goroutine did not exit after ctx cancel") + } +} diff --git a/internal/io/dlog/loggers/fout.go b/internal/io/dlog/loggers/fout.go index 6888d40..0d3fde5 100644 --- a/internal/io/dlog/loggers/fout.go +++ b/internal/io/dlog/loggers/fout.go @@ -4,16 +4,47 @@ import ( "context" "sync" "time" + + "github.com/mimecast/dtail/internal/config" ) +// fout logs to both a file and stdout. It is the default client logger. +// +// The two things a client emits are deliberately split at this seam: +// - Diagnostics (connection INFO/WARN/ERROR/etc.) arrive via Log/LogWithColors +// and are ALWAYS written to both stdout and the file — they are the small, +// useful audit trail the daily log file is meant to keep. +// - Retrieved payload (the bulk dcat/dgrep/dtail output) arrives via +// Raw/RawWithColors. It always reaches stdout/terminal, but it is teed to +// the file only when logPayload is set (opt-in via --log-payload / +// Client.LogPayload). By default the file receives no payload, so a bulk +// dcat no longer silently grows the daily log file by the full payload size. type fout struct { - file *file - stdout *stdout + file Logger + stdout Logger + logPayload bool } -// Logs to both, a file and stdout +// newFout builds the default client logger. Whether retrieved payload is teed +// to the file is decided once at construction from the client config. func newFout(strategy Strategy) *fout { - return &fout{file: newFile(strategy), stdout: newStdout()} + return newFoutWithSinks(newFile(strategy), newStdout(), clientLogPayloadEnabled()) +} + +// newFoutWithSinks builds a fout over injectable sinks and an explicit payload +// switch. Production uses newFout (concrete file+stdout, config-driven switch); +// tests inject fakes to assert that diagnostics always reach the file while +// payload reaches it only when opted in. +func newFoutWithSinks(file, stdout Logger, logPayload bool) *fout { + return &fout{file: file, stdout: stdout, logPayload: logPayload} +} + +// clientLogPayloadEnabled reports whether the client has opted in to teeing the +// full retrieved payload into the daily log file. Default (false) keeps only +// diagnostics in the file. config.Client is nil-guarded because a logger can be +// constructed in early/unit contexts before config.Setup has populated it. +func clientLogPayloadEnabled() bool { + return config.Client != nil && config.Client.LogPayload } func (f *fout) Start(ctx context.Context, wg *sync.WaitGroup) { @@ -35,17 +66,42 @@ func (f *fout) Log(now time.Time, message string) { func (f *fout) LogWithColors(now time.Time, message, coloredMessage string) { f.stdout.LogWithColors(now, "", coloredMessage) + // The file logger does not support colors, so write the plain message via + // Log (its LogWithColors would route to RawWithColors, which panics). f.file.Log(now, message) } +// Raw writes retrieved payload. It always reaches stdout/terminal; it is teed +// to the file sink only when the client opted in via --log-payload / +// Client.LogPayload. By default the file is left payload-free. func (f *fout) Raw(now time.Time, message string) { f.stdout.Raw(now, message) - f.file.Raw(now, message) + if f.logPayload { + f.file.Raw(now, message) + } } func (f *fout) RawWithColors(now time.Time, message, coloredMessage string) { f.stdout.RawWithColors(now, "", coloredMessage) - f.file.Raw(now, message) + // Same opt-in gate as Raw; the file gets the plain (uncolored) payload. + if f.logPayload { + f.file.Raw(now, message) + } +} + +// RawFileOnly tees retrieved payload into the daily log FILE sink only, never to +// stdout, honoring the same --log-payload / Client.LogPayload opt-in as Raw. +// +// It exists for the serverless direct-output path: that path writes +// payload straight to its own stdout sink and bypasses Raw entirely, so without +// this hook --log-payload would silently no longer tee payload to the file in +// serverless mode. The caller (the serverless output writer) already emits the +// payload bytes to stdout itself, so this method deliberately writes ONLY to the +// file to keep stdout byte-identical whether or not --log-payload is set. +func (f *fout) RawFileOnly(now time.Time, message string) { + if f.logPayload { + f.file.Raw(now, message) + } } func (f *fout) Flush() { f.stdout.Flush(); f.file.Flush() } diff --git a/internal/io/dlog/loggers/fout_test.go b/internal/io/dlog/loggers/fout_test.go new file mode 100644 index 0000000..0788f01 --- /dev/null +++ b/internal/io/dlog/loggers/fout_test.go @@ -0,0 +1,199 @@ +package loggers + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/mimecast/dtail/internal/config" +) + +// recordingSink is an injectable Logger that records which messages reached it +// via the diagnostic path (Log) versus the payload path (Raw). It lets the fout +// routing tests assert exactly which sink gets diagnostics and which gets +// payload without touching real files or stdout. +type recordingSink struct { + mutex sync.Mutex + logs []string + raws []string +} + +func (r *recordingSink) Log(now time.Time, message string) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.logs = append(r.logs, message) +} + +func (r *recordingSink) LogWithColors(now time.Time, message, colored string) { + r.Log(now, message) +} + +func (r *recordingSink) Raw(now time.Time, message string) { + r.mutex.Lock() + defer r.mutex.Unlock() + r.raws = append(r.raws, message) +} + +func (r *recordingSink) RawWithColors(now time.Time, message, colored string) { + r.Raw(now, message) +} + +func (r *recordingSink) Start(ctx context.Context, wg *sync.WaitGroup) { wg.Done() } +func (r *recordingSink) Flush() {} +func (r *recordingSink) Pause() {} +func (r *recordingSink) Resume() {} +func (r *recordingSink) Rotate() {} +func (r *recordingSink) SupportsColors() bool { return false } + +func (r *recordingSink) logCount() int { + r.mutex.Lock() + defer r.mutex.Unlock() + return len(r.logs) +} + +func (r *recordingSink) rawCount() int { + r.mutex.Lock() + defer r.mutex.Unlock() + return len(r.raws) +} + +var _ Logger = (*recordingSink)(nil) + +// TestFoutDefaultKeepsPayloadOutOfFile proves the footgun fix: with LogPayload +// disabled (the default), diagnostics reach the file sink but retrieved payload +// does not — while stdout still receives BOTH, so the terminal output is +// unchanged. +func TestFoutDefaultKeepsPayloadOutOfFile(t *testing.T) { + file := &recordingSink{} + stdout := &recordingSink{} + f := newFoutWithSinks(file, stdout, false) + + now := time.Now() + f.Log(now, "diagnostic-line") // connection INFO/WARN/ERROR audit line + f.Raw(now, "payload-line-1\n") // bulk dcat/dgrep/dtail output + f.Raw(now, "payload-line-2\n") + + // File: exactly the diagnostic, no payload. + if got := file.logCount(); got != 1 { + t.Fatalf("file diagnostics: got %d, want 1", got) + } + if got := file.rawCount(); got != 0 { + t.Fatalf("file payload leaked: got %d raws, want 0 (default must be diagnostics-only)", got) + } + // Stdout: diagnostic + both payload lines (terminal output unchanged). + if got := stdout.logCount(); got != 1 { + t.Fatalf("stdout diagnostics: got %d, want 1", got) + } + if got := stdout.rawCount(); got != 2 { + t.Fatalf("stdout payload: got %d, want 2", got) + } +} + +// TestFoutOptInTeesPayloadToFile proves --log-payload / Client.LogPayload +// restores the legacy behaviour: payload is teed to the file too, and +// diagnostics still land in the file. +func TestFoutOptInTeesPayloadToFile(t *testing.T) { + file := &recordingSink{} + stdout := &recordingSink{} + f := newFoutWithSinks(file, stdout, true) + + now := time.Now() + f.Log(now, "diagnostic-line") + f.Raw(now, "payload-line-1\n") + f.Raw(now, "payload-line-2\n") + + if got := file.logCount(); got != 1 { + t.Fatalf("file diagnostics: got %d, want 1", got) + } + if got := file.rawCount(); got != 2 { + t.Fatalf("file payload with opt-in: got %d, want 2", got) + } + if got := stdout.rawCount(); got != 2 { + t.Fatalf("stdout payload: got %d, want 2", got) + } +} + +// TestFoutServerErrorDiagnosticReachesFileByDefault is the end-to-end guard for +// the ReportServerError footgun: a server-error audit line, sent via the +// diagnostic (Log) path over a REAL on-disk file sink with LogPayload=false, +// must actually land in the daily log file — while bulk payload (Raw) must not. +// This exercises the real file-write path the regression would have skipped. +func TestFoutServerErrorDiagnosticReachesFileByDefault(t *testing.T) { + tmp := t.TempDir() + prevCommon := config.Common + config.Common = &config.CommonConfig{LogDir: tmp} + t.Cleanup(func() { config.Common = prevCommon }) + + // SignalRotation with a fixed FileBase gives a deterministic file name. + fileSink := newFile(Strategy{Rotation: SignalRotation, FileBase: "servererr"}) + stdout := &recordingSink{} + f := newFoutWithSinks(fileSink, stdout, false) + + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + wg.Add(1) + f.Start(ctx, &wg) + + now := time.Now() + serverError := "SERVER|srv1|ERROR|journal file targets require server capability journal-v1" + f.Log(now, serverError) // diagnostic / audit line (ReportServerError path) + f.Raw(now, "payload\n") // bulk payload, must stay out of the file + + f.Flush() + cancel() + wg.Wait() + + content, err := os.ReadFile(filepath.Join(tmp, "servererr.log")) + if err != nil { + t.Fatalf("reading log file: %v", err) + } + if !strings.Contains(string(content), serverError) { + t.Fatalf("server-error diagnostic missing from default log file:\n%s", content) + } + if strings.Contains(string(content), "payload") { + t.Fatalf("payload leaked into the default log file:\n%s", content) + } + // Stdout still receives both (terminal output unchanged). + if stdout.logCount() != 1 || stdout.rawCount() != 1 { + t.Fatalf("stdout must receive both diagnostic and payload; logs=%d raws=%d", + stdout.logCount(), stdout.rawCount()) + } +} + +// TestFoutWithColorsRouting mirrors the two tests above for the colored paths +// (LogWithColors always to file, RawWithColors gated by the opt-in), because the +// client uses the *WithColors variants when terminal colors are enabled. +func TestFoutWithColorsRouting(t *testing.T) { + now := time.Now() + + t.Run("default", func(t *testing.T) { + file := &recordingSink{} + stdout := &recordingSink{} + f := newFoutWithSinks(file, stdout, false) + f.LogWithColors(now, "diag", "\x1b[1mdiag\x1b[0m") + f.RawWithColors(now, "payload\n", "\x1b[1mpayload\x1b[0m\n") + if got := file.logCount(); got != 1 { + t.Fatalf("file diagnostics: got %d, want 1", got) + } + if got := file.rawCount(); got != 0 { + t.Fatalf("file payload leaked: got %d, want 0", got) + } + if got := stdout.rawCount(); got != 1 { + t.Fatalf("stdout payload: got %d, want 1", got) + } + }) + + t.Run("optin", func(t *testing.T) { + file := &recordingSink{} + stdout := &recordingSink{} + f := newFoutWithSinks(file, stdout, true) + f.RawWithColors(now, "payload\n", "\x1b[1mpayload\x1b[0m\n") + if got := file.rawCount(); got != 1 { + t.Fatalf("file payload with opt-in: got %d, want 1", got) + } + }) +} diff --git a/internal/io/dlog/loggers/stdout.go b/internal/io/dlog/loggers/stdout.go index b024243..7067305 100644 --- a/internal/io/dlog/loggers/stdout.go +++ b/internal/io/dlog/loggers/stdout.go @@ -1,27 +1,76 @@ package loggers import ( + "bufio" "context" "fmt" + "io" + "os" "sync" "time" ) +const ( + // stdoutWriterBufSize is the size of the bufio buffer wrapping os.Stdout. + // The old path did one fmt.Println (one write syscall) per received line; + // buffering lets bulk payload batch into ~one syscall per bufferful. bufio + // auto-flushes when full so high-throughput output never stalls. + stdoutWriterBufSize = 64 * 1024 + // stdoutIdleFlushInterval bounds how long buffered output may sit unwritten + // when output goes idle (follow/interactive trickling a few lines). Without + // it, low-volume output would be stuck behind the buffer, so follow/tail + // would appear frozen on the terminal. + stdoutIdleFlushInterval = 100 * time.Millisecond +) + type stdout struct { pauseCh chan struct{} resumeCh chan struct{} + writer *bufio.Writer mutex sync.Mutex } +var _ Logger = (*stdout)(nil) + func newStdout() *stdout { + return newStdoutWriter(os.Stdout) +} + +// newStdoutWriter builds a stdout logger over an arbitrary sink. Production +// uses os.Stdout; tests inject a counting writer to assert that buffering +// batches many lines into few underlying writes. The bufio writer is created +// eagerly so the logger is usable even when Start() is never called (e.g. in +// isolated unit tests); idle/shutdown flushing is only driven once Start() +// spawns the flush goroutine. +func newStdoutWriter(w io.Writer) *stdout { return &stdout{ pauseCh: make(chan struct{}), resumeCh: make(chan struct{}), + writer: bufio.NewWriterSize(w, stdoutWriterBufSize), } } func (s *stdout) Start(ctx context.Context, wg *sync.WaitGroup) { - wg.Done() + // Background flusher: with a real buffer, low-volume (follow/interactive) + // output would otherwise sit unwritten until the buffer fills. The ticker + // flushes any partial buffer promptly, and ctx.Done triggers a final flush + // so no buffered output is lost on clean shutdown. wg.Done is deferred to + // the goroutine so callers (ClientRuntime.Stop -> wg.Wait) block until the + // final flush has happened. + go func() { + defer wg.Done() + ticker := time.NewTicker(stdoutIdleFlushInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + s.Flush() + case <-ctx.Done(): + s.Flush() + return + } + } + }() } func (s *stdout) Log(now time.Time, message string) { @@ -42,27 +91,48 @@ func (s *stdout) RawWithColors(now time.Time, message, coloredMessage string) { func (s *stdout) log(message string, nl bool) { s.mutex.Lock() - defer s.mutex.Unlock() - select { case <-s.pauseCh: - // Pause until resumed. + // Wait for Resume without holding the mutex: the prompt path calls + // dlog after the user answers while Pause is still active; holding the + // mutex here would deadlock (Info blocks on Lock, Resume never runs). + s.mutex.Unlock() <-s.resumeCh + s.mutex.Lock() default: } + defer s.mutex.Unlock() + // Buffered writes: fmt.Fprint(ln) into the bufio.Writer batches many lines + // into one write syscall. Errors are intentionally ignored — a logger that + // cannot write to stdout has nowhere to report the failure. if nl { - fmt.Println(message) + _, _ = fmt.Fprintln(s.writer, message) return } - fmt.Print(message) + _, _ = fmt.Fprint(s.writer, message) +} + +func (s *stdout) Pause() { + // Flush before pausing so all output produced so far is visible before the + // caller (interactive prompt / stats interrupt) writes directly to stdout, + // preserving the ordering the unbuffered path used to give for free. The + // pauseCh handshake below is unchanged so the pause semantics (and the + // deadlock guarantees exercised by the unit tests) are preserved. + s.mutex.Lock() + _ = s.writer.Flush() + s.mutex.Unlock() + s.pauseCh <- struct{}{} } -func (s *stdout) Pause() { s.pauseCh <- struct{}{} } func (s *stdout) Resume() { s.resumeCh <- struct{}{} } func (s *stdout) Flush() { - // This is empty because it isn't doing anything but has to satisfy the interface. + s.mutex.Lock() + defer s.mutex.Unlock() + // bufio.Flush is a no-op when nothing is buffered, so calling this on every + // idle tick is cheap. + _ = s.writer.Flush() } func (s *stdout) Rotate() { diff --git a/internal/io/dlog/loggers/stdout_test.go b/internal/io/dlog/loggers/stdout_test.go new file mode 100644 index 0000000..af8c9e5 --- /dev/null +++ b/internal/io/dlog/loggers/stdout_test.go @@ -0,0 +1,190 @@ +package loggers + +import ( + "bytes" + "context" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// countingWriter records how many times Write is called and accumulates all +// bytes, so tests can assert that buffering batches many logical lines into a +// small number of underlying writes while preserving content and order. +type countingWriter struct { + mutex sync.Mutex + buf bytes.Buffer + writes int +} + +func (c *countingWriter) Write(p []byte) (int, error) { + c.mutex.Lock() + defer c.mutex.Unlock() + c.writes++ + return c.buf.Write(p) +} + +func (c *countingWriter) String() string { + c.mutex.Lock() + defer c.mutex.Unlock() + return c.buf.String() +} + +func (c *countingWriter) Writes() int { + c.mutex.Lock() + defer c.mutex.Unlock() + return c.writes +} + +// TestStdoutBuffersAndPreservesOrder proves the buffered stdout path batches +// many lines into far fewer underlying writes than one-per-line, and that a +// Flush emits the exact content in order (nothing dropped or reordered). +func TestStdoutBuffersAndPreservesOrder(t *testing.T) { + cw := &countingWriter{} + s := newStdoutWriter(cw) + + const n = 1000 + var want strings.Builder + for i := 0; i < n; i++ { + line := "line-" + strconv.Itoa(i) + s.Raw(time.Now(), line+"\n") + want.WriteString(line + "\n") + } + + // Before flush the small lines must still be batched in the bufio buffer: + // with per-line writes this would already be n writes. + if got := cw.Writes(); got >= n { + t.Fatalf("expected buffering to batch writes, got %d writes for %d lines", got, n) + } + + s.Flush() + + if got := cw.String(); got != want.String() { + t.Fatalf("content mismatch after flush:\n got %q\nwant %q", got, want.String()) + } + // 1000 short lines fit in a handful of 64KB flushes, definitely far below n. + if got := cw.Writes(); got > n/10 { + t.Fatalf("expected far fewer than %d writes, got %d", n/10, got) + } +} + +// TestStdoutFlushOnPause proves output produced before Pause() is flushed to +// the sink before Pause returns, so an interactive prompt writing directly to +// the terminal never appears ahead of already-logged output. +func TestStdoutFlushOnPause(t *testing.T) { + cw := &countingWriter{} + s := newStdoutWriter(cw) + + s.Raw(time.Now(), "before-pause\n") + + // Pause blocks on the pauseCh handshake until a concurrent log() consumes + // the token (the existing pause semantics). Drive a stream of log() calls + // so one is guaranteed to pick up the token and let Pause() return, + // mirroring the real prompt flow where logging goroutines are active. + paused := make(chan struct{}) + go func() { + s.Pause() + close(paused) + }() + go func() { + for { + select { + case <-paused: + return + default: + s.Log(time.Now(), "consumes-pause") + time.Sleep(time.Millisecond) + } + } + }() + <-paused + + // Pause() flushes before the handshake, so the pre-pause line must already + // be in the sink now regardless of the buffer. + if got := cw.String(); !strings.Contains(got, "before-pause") { + t.Fatalf("expected buffered output flushed on Pause, got %q", got) + } + s.Resume() +} + +// TestStdoutIdleFlush proves that a single low-volume line (follow/tail style) +// is not stuck behind the buffer: the Start() idle ticker flushes it promptly +// without any explicit Flush call. +func TestStdoutIdleFlush(t *testing.T) { + cw := &countingWriter{} + s := newStdoutWriter(cw) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var wg sync.WaitGroup + wg.Add(1) + s.Start(ctx, &wg) + + s.Raw(time.Now(), "follow-line\n") + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if strings.Contains(cw.String(), "follow-line") { + cancel() + wg.Wait() + return + } + time.Sleep(5 * time.Millisecond) + } + cancel() + wg.Wait() + t.Fatal("follow-style line stuck behind buffer; idle flush did not emit it") +} + +// TestStdoutFinalFlushOnClose proves no buffered output is lost on clean +// shutdown: data logged just before ctx cancel is flushed before the Start +// goroutine (and thus wg.Wait) returns. +func TestStdoutFinalFlushOnClose(t *testing.T) { + cw := &countingWriter{} + s := newStdoutWriter(cw) + + ctx, cancel := context.WithCancel(context.Background()) + var wg sync.WaitGroup + wg.Add(1) + s.Start(ctx, &wg) + + s.Raw(time.Now(), "last-line-before-exit\n") + cancel() + wg.Wait() + + if got := cw.String(); !strings.Contains(got, "last-line-before-exit") { + t.Fatalf("buffered output lost on shutdown, got %q", got) + } +} + +// Regression: during an interactive prompt, dlog.Common.Pause() unblocks when some +// goroutine hits stdout.log(); that goroutine must not hold the stdout mutex while +// waiting on resume, or dlog.Client.Info from the prompt callback deadlocks forever. +func TestStdoutSecondLogDuringPauseWaitDoesNotDeadlock(t *testing.T) { + s := newStdout() + + go s.Pause() + time.Sleep(50 * time.Millisecond) + + go func() { + s.Log(time.Now(), "first log consumes pause and waits on resume") + }() + time.Sleep(50 * time.Millisecond) + + secondDone := make(chan struct{}) + go func() { + s.Log(time.Now(), "second log must acquire mutex while first waits for Resume") + close(secondDone) + }() + + select { + case <-secondDone: + case <-time.After(2 * time.Second): + t.Fatal("deadlock: second Log blocked on mutex while first waits for Resume") + } + + s.Resume() + time.Sleep(50 * time.Millisecond) +} |
