summaryrefslogtreecommitdiff
path: root/internal/io
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-07-22 23:51:18 +0300
committerPaul Buetow <paul@buetow.org>2026-07-22 23:51:18 +0300
commit849951be1d1a7ee9f9302006ccb187bf5b4e36f3 (patch)
tree496c924a03a9ea6212e29bb4699e268066ebad81 /internal/io
parentbf78b3abffee6d49c08ca2980156afc455994969 (diff)
feat: DTail fork — server/client feature development
Squashed development of the snonux/dtail fork's product code (internal/, cmd/) since diverging from mimecast/dtail. Major areas: - Read/output path: the former "turbo" channel-less path is now the single, default server-side read/output path for cat/grep/tail and MapReduce; the old channel-based path and its config/env toggles were removed. - MapReduce: single aggregate implementation (server + serverless) fed directly by a processor pipeline, with input-exhausted finalization via the shutdown coordinator; high-concurrency and data-race fixes. - Journal source reads (journal:unit.service) via journalctl, Linux-gated behind a journal-v1 capability. - Auth-key fast reconnect: in-memory per-user public-key cache with TTL/max-keys, registered over an authenticated session (AUTHKEY), checked before authorized_keys. - Interactive query reload (--interactive-query) with SESSION START/UPDATE generation boundaries and capability negotiation. - Client-side deadlines: --timeout / --shutdownAfter as context deadlines; follow shutdown handling. - Client logging: diagnostics-only daily log by default, opt-in payload tee via --log-payload. - Numerous correctness fixes (buffer-pool double-recycle races, EOF-sentinel leaks, glob-expansion cap, TOCTOU in CSV parsing) with accompanying unit tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'internal/io')
-rw-r--r--internal/io/dlog/dlog.go226
-rw-r--r--internal/io/dlog/dlog_test.go54
-rw-r--r--internal/io/dlog/loggers/file.go122
-rw-r--r--internal/io/dlog/loggers/file_test.go183
-rw-r--r--internal/io/dlog/loggers/fout.go68
-rw-r--r--internal/io/dlog/loggers/fout_test.go199
-rw-r--r--internal/io/dlog/loggers/stdout.go86
-rw-r--r--internal/io/dlog/loggers/stdout_test.go190
-rw-r--r--internal/io/dlog/rawlog_test.go76
-rw-r--r--internal/io/dlog/rotation.go26
-rw-r--r--internal/io/dlog/rotation_test.go61
-rw-r--r--internal/io/fs/catfile.go14
-rw-r--r--internal/io/fs/filereader.go8
-rw-r--r--internal/io/fs/permissions/permission.go1
-rw-r--r--internal/io/fs/permissions/permission_linuxacl.go1
-rw-r--r--internal/io/fs/permissions/permission_test.go1
-rw-r--r--internal/io/fs/readfile.go238
-rw-r--r--internal/io/fs/readfile_nozstd.go16
-rw-r--r--internal/io/fs/readfile_processor.go359
-rw-r--r--internal/io/fs/readfile_processor_optimized.go430
-rw-r--r--internal/io/fs/readfile_processor_test.go869
-rw-r--r--internal/io/fs/readfile_zstd.go20
-rw-r--r--internal/io/fs/readfilelcontext.go209
-rw-r--r--internal/io/fs/rootedpath.go96
-rw-r--r--internal/io/fs/rootedpath_test.go57
-rw-r--r--internal/io/fs/tailfile.go14
-rw-r--r--internal/io/fs/validatedreadtarget.go148
-rw-r--r--internal/io/fs/validatedreadtarget_test.go218
-rw-r--r--internal/io/journal/filter.go253
-rw-r--r--internal/io/journal/reader.go303
-rw-r--r--internal/io/journal/reader_test.go754
-rw-r--r--internal/io/journal/reader_unsupported.go45
-rw-r--r--internal/io/journal/testhelper/mock.go397
-rw-r--r--internal/io/journal/testhelper/mock_test.go247
-rw-r--r--internal/io/line/line.go18
-rw-r--r--internal/io/line/processor.go22
-rw-r--r--internal/io/pool/bytesbuffer.go4
-rw-r--r--internal/io/pool/scanner_pool.go85
-rw-r--r--internal/io/signal/signal.go45
39 files changed, 5623 insertions, 540 deletions
diff --git a/internal/io/dlog/dlog.go b/internal/io/dlog/dlog.go
index 258fb68..e28b442 100644
--- a/internal/io/dlog/dlog.go
+++ b/internal/io/dlog/dlog.go
@@ -31,36 +31,6 @@ var Common *DLog
var mutex sync.Mutex
var started bool
-// Start logger(s).
-func Start(ctx context.Context, wg *sync.WaitGroup, sourceProcess source.Source) {
- mutex.Lock()
- defer mutex.Unlock()
-
- if started {
- Common.FatalPanic("Logger already started")
- }
-
- Client = new(sourceProcess, source.Client)
- Server = new(sourceProcess, source.Server)
- Common = Client
- if sourceProcess == source.Server {
- Common = Server
- }
-
- var wg2 sync.WaitGroup
- wg2.Add(2)
- go Client.start(ctx, &wg2)
- go Server.start(ctx, &wg2)
-
- go rotation(ctx)
- go func() {
- wg2.Wait()
- wg.Done()
- }()
-
- started = true
-}
-
// DLog is the DTail logger.
type DLog struct {
logger loggers.Logger
@@ -94,62 +64,43 @@ func new(sourceProcess, sourcePackage source.Source) *DLog {
}
}
-func (d *DLog) start(ctx context.Context, wg *sync.WaitGroup) {
- defer wg.Done()
- var wg2 sync.WaitGroup
- wg2.Add(1)
- d.logger.Start(ctx, &wg2)
- <-ctx.Done()
- wg2.Wait()
-}
+// Start logger(s).
+func Start(ctx context.Context, wg *sync.WaitGroup, sourceProcess source.Source) {
+ mutex.Lock()
+ defer mutex.Unlock()
-func (d *DLog) log(level level, args []interface{}) string {
- if d.maxLevel < level {
- return ""
+ if started {
+ Common.FatalPanic("Logger already started")
}
- sb := pool.BuilderBuffer.Get().(*strings.Builder)
- defer pool.RecycleBuilderBuffer(sb)
- now := time.Now()
- switch d.sourceProcess {
- case source.Client:
- sb.WriteString(d.sourcePackage.String())
- sb.WriteString(protocol.FieldDelimiter)
- sb.WriteString(d.hostname)
- sb.WriteString(protocol.FieldDelimiter)
- sb.WriteString(level.String())
- default:
- sb.WriteString(level.String())
- sb.WriteString(protocol.FieldDelimiter)
- sb.WriteString(now.Format("0102-150405"))
+ Client = new(sourceProcess, source.Client)
+ Server = new(sourceProcess, source.Server)
+ Common = Client
+ if sourceProcess == source.Server {
+ Common = Server
}
- sb.WriteString(protocol.FieldDelimiter)
- d.writeArgStrings(sb, args)
- message := sb.String()
- if !config.Client.TermColorsEnable || !d.logger.SupportsColors() {
- d.logger.Log(now, message)
- return message
- }
+ var wg2 sync.WaitGroup
+ wg2.Add(2)
+ go Client.start(ctx, &wg2)
+ go Server.start(ctx, &wg2)
- d.logger.LogWithColors(now, message, brush.Colorfy(message))
- return message
+ go rotation(ctx)
+ go func() {
+ wg2.Wait()
+ wg.Done()
+ }()
+
+ started = true
}
-func (d *DLog) writeArgStrings(sb *strings.Builder, args []interface{}) {
- for i, arg := range args {
- if i > 0 {
- sb.WriteString(protocol.FieldDelimiter)
- }
- switch v := arg.(type) {
- case string:
- sb.WriteString(v)
- case error:
- sb.WriteString(v.Error())
- default:
- sb.WriteString(fmt.Sprintf("%v", v))
- }
- }
+func (d *DLog) start(ctx context.Context, wg *sync.WaitGroup) {
+ defer wg.Done()
+ var wg2 sync.WaitGroup
+ wg2.Add(1)
+ d.logger.Start(ctx, &wg2)
+ <-ctx.Done()
+ wg2.Wait()
}
// FatalPanic terminates the process with a fatal error.
@@ -192,8 +143,34 @@ func (d *DLog) Debug(args ...interface{}) string {
return d.log(Debug, args)
}
+// TraceEnabled reports whether trace-level logging is currently active.
+//
+// It performs exactly the same maxLevel comparison as Trace's internal
+// early-return (see below), letting callers on per-line hot paths gate the
+// whole trace call — the variadic []interface{} slice allocation plus the
+// interface boxing of every non-pointer argument (uint64 line counts via
+// runtime.convT64, strings via convTstring) — behind one cheap, inlinable,
+// allocation-free branch. Without this guard those args are boxed at the call
+// site before Trace even runs, so Trace's own early-return cannot save them.
+//
+// The receiver is nil-safe so call sites need no separate nil check on the
+// package-level loggers (Server/Client/Common), which stay nil until Start.
+// maxLevel is fixed at logger construction from config.Common.LogLevel, so the
+// result mirrors whatever level Trace itself would observe.
+func (d *DLog) TraceEnabled() bool {
+ return d != nil && d.maxLevel >= Trace
+}
+
// Trace logging.
func (d *DLog) Trace(args ...interface{}) string {
+ // Early check to avoid expensive runtime.Caller when trace is disabled
+ // This is a critical performance optimization for hot paths. Note that on
+ // per-line hot paths callers should additionally gate with TraceEnabled()
+ // so the argument boxing never happens; this check only saves runtime.Caller
+ // and the log formatting, not the caller-side boxing of args.
+ if d.maxLevel < Trace {
+ return ""
+ }
_, file, line, _ := runtime.Caller(1)
args = append(args, fmt.Sprintf("at %s:%d", file, line))
return d.log(Trace, args)
@@ -201,6 +178,10 @@ func (d *DLog) Trace(args ...interface{}) string {
// Devel used for development purpose only logging (e.g. "print" debugging).
func (d *DLog) Devel(args ...interface{}) string {
+ // Early check to avoid expensive runtime.Caller when devel is disabled
+ if d.maxLevel < Devel {
+ return ""
+ }
_, file, line, _ := runtime.Caller(1)
args = append(args, fmt.Sprintf("at %s:%d", file, line))
return d.log(Devel, args)
@@ -216,6 +197,46 @@ func (d *DLog) Raw(message string) string {
return message
}
+// payloadFileTeer is the optional capability of a logger that can tee retrieved
+// payload into its FILE sink without also writing it to stdout. Only the default
+// fout logger implements it; stdout/none loggers have no file sink and are
+// skipped via the type assertion in RawPayloadFileTee.
+type payloadFileTeer interface {
+ RawFileOnly(now time.Time, message string)
+}
+
+// RawPayloadFileTee writes retrieved payload to the logger's FILE sink only
+// (never stdout), honoring the client's --log-payload / Client.LogPayload
+// opt-in. It is used by the serverless direct-output path, which emits payload
+// straight to stdout and therefore bypasses the fout logger's own Raw tee. When
+// the active logger has no file sink (stdout/none) or payload teeing is
+// disabled, this is a no-op. stdout output is unaffected: the caller writes the
+// same payload bytes to stdout itself, and this method only adds the file tee.
+func (d *DLog) RawPayloadFileTee(message string) {
+ if teer, ok := d.logger.(payloadFileTeer); ok {
+ teer.RawFileOnly(time.Now(), message)
+ }
+}
+
+// RawLog writes a pre-formatted message through the DIAGNOSTIC (Log) sink, so it
+// reaches both stdout and — in the default fout logger — the daily log file.
+//
+// It differs from Raw, which uses the PAYLOAD sink: with the default
+// Client.LogPayload=false, Raw is gated out of the file (bulk dcat/dgrep/dtail
+// output must not grow the log). RawLog is for audit-worthy, already-formatted
+// lines such as server-error reports, which must always be kept in the file like
+// other diagnostics rather than being treated as bulk payload. The message is
+// written verbatim (no level/hostname prefix); callers pre-format it and must
+// NOT append a trailing newline, since the Log sink appends one.
+func (d *DLog) RawLog(message string) string {
+ if !config.Client.TermColorsEnable || !d.logger.SupportsColors() {
+ d.logger.Log(time.Now(), message)
+ return message
+ }
+ d.logger.LogWithColors(time.Now(), message, brush.Colorfy(message))
+ return message
+}
+
// Mapreduce logging.
func (d *DLog) Mapreduce(table string, data map[string]interface{}) string {
args := make([]interface{}, len(data)+1)
@@ -269,3 +290,52 @@ func (d *DLog) Pause() { d.logger.Pause() }
// Resume the logging.
func (d *DLog) Resume() { d.logger.Resume() }
+
+func (d *DLog) log(level level, args []interface{}) string {
+ if d.maxLevel < level {
+ return ""
+ }
+ sb := pool.BuilderBuffer.Get().(*strings.Builder)
+ defer pool.RecycleBuilderBuffer(sb)
+ now := time.Now()
+
+ switch d.sourceProcess {
+ case source.Client:
+ sb.WriteString(d.sourcePackage.String())
+ sb.WriteString(protocol.FieldDelimiter)
+ sb.WriteString(d.hostname)
+ sb.WriteString(protocol.FieldDelimiter)
+ sb.WriteString(level.String())
+ default:
+ sb.WriteString(level.String())
+ sb.WriteString(protocol.FieldDelimiter)
+ sb.WriteString(now.Format("0102-150405"))
+ }
+ sb.WriteString(protocol.FieldDelimiter)
+ d.writeArgStrings(sb, args)
+
+ message := sb.String()
+ if !config.Client.TermColorsEnable || !d.logger.SupportsColors() {
+ d.logger.Log(now, message)
+ return message
+ }
+
+ d.logger.LogWithColors(now, message, brush.Colorfy(message))
+ return message
+}
+
+func (d *DLog) writeArgStrings(sb *strings.Builder, args []interface{}) {
+ for i, arg := range args {
+ if i > 0 {
+ sb.WriteString(protocol.FieldDelimiter)
+ }
+ switch v := arg.(type) {
+ case string:
+ sb.WriteString(v)
+ case error:
+ sb.WriteString(v.Error())
+ default:
+ sb.WriteString(fmt.Sprintf("%v", v))
+ }
+ }
+}
diff --git a/internal/io/dlog/dlog_test.go b/internal/io/dlog/dlog_test.go
new file mode 100644
index 0000000..87e834a
--- /dev/null
+++ b/internal/io/dlog/dlog_test.go
@@ -0,0 +1,54 @@
+package dlog
+
+import "testing"
+
+// TestTraceEnabled verifies that TraceEnabled mirrors Trace's internal
+// maxLevel < Trace early-return: it must report true exactly when the
+// configured level is Trace or higher (Devel/All disable trace? no — the level
+// ladder is monotonically increasing, so any level >= Trace enables trace),
+// and false for every level below Trace.
+func TestTraceEnabled(t *testing.T) {
+ tests := []struct {
+ name string
+ level level
+ want bool
+ }{
+ {"none", None, false},
+ {"fatal", Fatal, false},
+ {"error", Error, false},
+ {"warn", Warn, false},
+ {"info", Info, false},
+ {"default", Default, false},
+ {"verbose", Verbose, false},
+ {"debug", Debug, false},
+ {"devel", Devel, false},
+ {"trace", Trace, true},
+ {"all", All, true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ d := &DLog{maxLevel: tc.level}
+ if got := d.TraceEnabled(); got != tc.want {
+ t.Fatalf("TraceEnabled() at level %v = %v, want %v",
+ tc.level, got, tc.want)
+ }
+ // TraceEnabled must agree with what Trace itself would do: Trace
+ // returns "" (no work) exactly when trace is disabled.
+ traceDidWork := d.maxLevel >= Trace
+ if traceDidWork != tc.want {
+ t.Fatalf("TraceEnabled() disagrees with Trace's own gate at level %v", tc.level)
+ }
+ })
+ }
+}
+
+// TestTraceEnabledNilSafe guards the footgun that call sites rely on:
+// TraceEnabled is invoked on the package-level loggers (Server/Client/Common),
+// which are nil until Start runs. A nil receiver must report false, not panic.
+func TestTraceEnabledNilSafe(t *testing.T) {
+ var d *DLog
+ if d.TraceEnabled() {
+ t.Fatal("nil *DLog.TraceEnabled() must be false")
+ }
+}
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