From a756ea85c1d93a7e92436f478ee59ec8c99ea14c Mon Sep 17 00:00:00 2001 From: "r.inyakin" Date: Wed, 5 Aug 2026 16:52:53 +0300 Subject: [PATCH] recheck: added recheck after start inotify Added a check to see if something was written to the file between the end of reading the file and the launch of inotify watcher. The implementation repeats the implementation of the original `tail` utility. Closes TNTP-3131 --- recheck_test.go | 129 ++++++++++++++++++++++++++++++++++++++++++++++++ tail.go | 75 ++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 recheck_test.go 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 {