diff --git a/recheck_test.go b/recheck_test.go new file mode 100644 index 0000000..e4b8343 --- /dev/null +++ b/recheck_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 FOSS contributors of https://github.com/tarantool/go-tail + +package tail + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +const ( + watchDelay = time.Second + windowSettle = 200 * time.Millisecond + lineWait = 10 * time.Second +) + +func stallBeforeWatch(t *testing.T) { + t.Helper() + + delayBeforeWatch = watchDelay + t.Cleanup(func() { delayBeforeWatch = 0 }) +} + +func startTail(t *testing.T, path string, config Config) *Tail { + t.Helper() + + config.Logger = DiscardingLogger + tailer, err := TailFile(path, config) + if err != nil { + t.Fatalf("failed to tail %s: %v", path, err) + } + t.Cleanup(func() { tailer.Stop() }) + + return tailer +} + +func expectLine(t *testing.T, tailer *Tail, expected string) { + t.Helper() + + select { + case line, ok := <-tailer.Lines: + if !ok { + t.Fatalf("Lines channel closed while waiting for %q", expected) + } + if line.Err != nil { + t.Fatalf("error while waiting for %q: %v", expected, line.Err) + } + if line.Text != expected { + t.Fatalf("got line %q, want %q", line.Text, expected) + } + case <-time.After(lineWait): + t.Fatalf("timeout waiting for line %q", expected) + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write %s: %v", path, err) + } +} + +func appendFile(t *testing.T, path, content string) { + t.Helper() + + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatalf("failed to open %s for append: %v", path, err) + } + defer file.Close() + + if _, err := file.WriteString(content); err != nil { + t.Fatalf("failed to append to %s: %v", path, err) + } +} + +func TestAppendWhileWatchIsNotArmed(t *testing.T) { + stallBeforeWatch(t) + + path := filepath.Join(t.TempDir(), "appended.log") + writeFile(t, path, "first\n") + + tailer := startTail(t, path, Config{Follow: true, ReOpen: true}) + expectLine(t, tailer, "first") + + time.Sleep(windowSettle) + appendFile(t, path, "second\nthird\n") + + expectLine(t, tailer, "second") + expectLine(t, tailer, "third") +} + +func TestTruncateWhileWatchIsNotArmed(t *testing.T) { + stallBeforeWatch(t) + + path := filepath.Join(t.TempDir(), "truncated.log") + writeFile(t, path, "a long first line\n") + + tailer := startTail(t, path, Config{Follow: true, ReOpen: true}) + expectLine(t, tailer, "a long first line") + + time.Sleep(windowSettle) + writeFile(t, path, "short\n") + + expectLine(t, tailer, "short") +} + +func TestReplaceWhileWatchIsNotArmed(t *testing.T) { + stallBeforeWatch(t) + + dir := t.TempDir() + path := filepath.Join(dir, "rotated.log") + writeFile(t, path, "before rotation\n") + + tailer := startTail(t, path, Config{Follow: true, ReOpen: true}) + expectLine(t, tailer, "before rotation") + + time.Sleep(windowSettle) + appendFile(t, path, "written just before the rename\n") + if err := os.Rename(path, path+".bak"); err != nil { + t.Fatalf("failed to rotate %s: %v", path, err) + } + writeFile(t, path, "after rotation\n") + + expectLine(t, tailer, "written just before the rename") + expectLine(t, tailer, "after rotation") +} diff --git a/tail.go b/tail.go index b0b51b0..27d00ef 100644 --- a/tail.go +++ b/tail.go @@ -106,6 +106,9 @@ type Tail struct { changes *watch.FileChanges // pendingDelete delays a rename/delete until the open file reaches EOF. pendingDelete bool + // pendingReopen delays a switch to the file that took over the + // name while the watch was being armed. + pendingReopen bool tomb.Tomb // provides: Done, Kill, Dying @@ -119,6 +122,12 @@ var ( DiscardingLogger = log.New(ioutil.Discard, "", 0) ) +// delayBeforeWatch stalls a tailer between reaching EOF and arming the +// watch, so that tests can reproduce writes landing in that window. It +// is the counterpart of the TAIL_TEST_SLEEP knob of coreutils and +// stays zero outside of tests. +var delayBeforeWatch time.Duration + // TailFile begins tailing the file. And returns a pointer to a Tail struct // and an error. An output stream is made available via the Tail.Lines // channel (e.g. to be looped and printed). To handle errors during tailing, @@ -399,15 +408,44 @@ func (tail *Tail) waitForChanges() error { return ErrStop } + if tail.pendingReopen { + tail.pendingReopen = false + // The watch is already on the file that occupies the name now, so + // only the descriptor has to catch up with it. Keep tail.changes. + tail.Logger.Printf("Re-opening replaced file %s ...", tail.Filename) + if err := tail.reopen(); err != nil { + return err + } + tail.Logger.Printf("Successfully reopened replaced %s", tail.Filename) + tail.openReader() + return nil + } + if tail.changes == nil { pos, err := tail.file.Seek(0, io.SeekCurrent) if err != nil { return err } + + // Widen the unwatched window on demand, the way coreutils + // does with its TAIL_TEST_SLEEP build knob, so that tests can + // hit a race that is only microseconds wide. + if delayBeforeWatch > 0 { + time.Sleep(delayBeforeWatch) + } + tail.changes, err = tail.watcher.ChangeEvents(&tail.Tomb, pos) if err != nil { return err } + + recheck, err := tail.recheckAfterWatch(pos) + if err != nil { + return err + } + if recheck { + return nil + } } // The write event should be handled first. @@ -437,6 +475,43 @@ func (tail *Tail) waitForChanges() error { } } +// recheckAfterWatch inspects the file once the watch is in place and reports +// whether there is something to read right away. +func (tail *Tail) recheckAfterWatch(pos int64) (bool, error) { + fi, err := tail.file.Stat() + if err != nil { + return false, err + } + + if tail.ReOpen { + if fs, err := os.Stat(tail.Filename); err == nil && !os.SameFile(fi, fs) { + tail.pendingReopen = true + } + } + + if !fi.Mode().IsRegular() { + return tail.pendingReopen, nil + } + + switch { + case fi.Size() < pos: + tail.Logger.Printf("Re-reading truncated file %s ...", tail.Filename) + if err := tail.seekTo(SeekInfo{Offset: 0, Whence: io.SeekStart}); err != nil { + return false, err + } + tail.lineNum = 0 + if tail.lineBuf != nil { + tail.lineBuf.Reset() + } + return true, nil + + case fi.Size() > pos: + return true, nil + } + + return tail.pendingReopen, nil +} + func (tail *Tail) openReader() { tail.lk.Lock() if tail.MaxLineSize > 0 {