Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions recheck_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
75 changes: 75 additions & 0 deletions tail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Loading