-
Notifications
You must be signed in to change notification settings - Fork 84
Prevent file and goroutine leaks during self-hosted log tailing #828
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -235,8 +235,16 @@ func tailFile(ctx context.Context, path string, out chan<- SelfHostedLogStreamIt | |
| TailLoop: | ||
| for { | ||
| select { | ||
| case line := <-t.Lines(): | ||
| out <- SelfHostedLogStreamItem{Line: line.String()} | ||
| case line, ok := <-t.Lines(): | ||
| if !ok { | ||
| break TailLoop | ||
| } | ||
| select { | ||
| case out <- SelfHostedLogStreamItem{Line: line.String()}: | ||
| case <-ctx.Done(): | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Check for context cancellation before writing to the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense especially since that channel is currently unbuffered (and it seems beneficial to keep that, or at most use a low capacity, so that we backpressure on the actual file read in go-tail to avoid high memory use with large log lines). |
||
| prefixedLogger.PrintVerbose("Stopping log tail for %s (stop requested)", path) | ||
| break TailLoop | ||
| } | ||
| case <-ctx.Done(): | ||
| prefixedLogger.PrintVerbose("Stopping log tail for %s (stop requested)", path) | ||
| break TailLoop | ||
|
|
@@ -329,6 +337,12 @@ func setupLogLocationTail(ctx context.Context, logLocation string, out chan<- Se | |
| return fmt.Errorf("fsnotify new: %s", err) | ||
| } | ||
|
|
||
| err = watcher.Add(logLocation) | ||
| if err != nil { | ||
| watcher.Close() | ||
| return fmt.Errorf("fsnotify add \"%s\": %s", logLocation, err) | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
|
|
||
| go func() { | ||
| defer watcher.Close() | ||
| for { | ||
|
|
@@ -358,7 +372,7 @@ func setupLogLocationTail(ctx context.Context, logLocation string, out chan<- Se | |
| } | ||
| } | ||
| } | ||
| if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename || event.Op&fsnotify.Chmod == fsnotify.Chmod { | ||
| if event.Op&fsnotify.Remove == fsnotify.Remove || event.Op&fsnotify.Rename == fsnotify.Rename { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not related to the rest of the changes, but I'm not sure it makes sense to close the tail if the permissions change. At the very least we can end up losing in-progress logs.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Seems reasonable. |
||
| tailCancel, ok := openFiles[event.Name] | ||
| if ok { | ||
| tailCancel() | ||
|
|
@@ -371,8 +385,6 @@ func setupLogLocationTail(ctx context.Context, logLocation string, out chan<- Se | |
| case <-ctx.Done(): | ||
| prefixedLogger.PrintVerbose("Log file fsnotify watcher received stop signal") | ||
| for fileName, tailCancel := range openFiles { | ||
| // TODO: This cancel might actually not be necessary since we are | ||
| // already canceling the parent context? | ||
| tailCancel() | ||
| delete(openFiles, fileName) | ||
| } | ||
|
|
@@ -382,11 +394,6 @@ func setupLogLocationTail(ctx context.Context, logLocation string, out chan<- Se | |
| } | ||
| }() | ||
|
|
||
| err = watcher.Add(logLocation) | ||
| if err != nil { | ||
| return fmt.Errorf("fsnotify add \"%s\": %s", logLocation, err) | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| package selfhosted | ||
|
|
||
| import ( | ||
| "context" | ||
| "io" | ||
| "log" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/papertrail/go-tail/follower" | ||
| "github.com/pganalyze/collector/util" | ||
| ) | ||
|
|
||
| // Verifies that tailFile reads new lines appended to a file and outputs them. | ||
| func TestTailFile_BasicLineReading(t *testing.T) { | ||
| dir := t.TempDir() | ||
| logPath := filepath.Join(dir, "test.log") | ||
|
|
||
| if err := os.WriteFile(logPath, nil, 0644); err != nil { | ||
| t.Fatalf("create file: %v", err) | ||
| } | ||
| ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | ||
| defer cancel() | ||
|
|
||
| linesCh := make(chan SelfHostedLogStreamItem, 10) | ||
| if err := tailFile(ctx, logPath, linesCh, testLogger()); err != nil { | ||
| t.Fatalf("tailFile: %v", err) | ||
| } | ||
|
|
||
| time.Sleep(100 * time.Millisecond) | ||
| if err := os.WriteFile(logPath, []byte("hello tail\n"), 0644); err != nil { | ||
| t.Fatalf("append line: %v", err) | ||
| } | ||
|
|
||
| lines := drainLines(linesCh, 1*time.Second) | ||
| if len(lines) == 0 { | ||
| t.Error("expected at least one line, got none") | ||
| } | ||
| } | ||
|
|
||
| // Verifies that when a log file is renamed (simulating logrotate), the follower detects the | ||
| // change and reads from the new file. | ||
| func TestTailFile_LogRotation(t *testing.T) { | ||
| dir := t.TempDir() | ||
| logPath := filepath.Join(dir, "postgresql.log") | ||
|
|
||
| if err := os.WriteFile(logPath, nil, 0644); err != nil { | ||
| t.Fatalf("create file: %v", err) | ||
| } | ||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| linesCh := make(chan SelfHostedLogStreamItem, 100) | ||
| if err := tailFile(ctx, logPath, linesCh, testLogger()); err != nil { | ||
| t.Fatalf("tailFile: %v", err) | ||
| } | ||
| time.Sleep(200 * time.Millisecond) | ||
|
|
||
| // Simulate logrotate: rename + create new file | ||
| if err := os.Rename(logPath, logPath+".1"); err != nil { | ||
| t.Fatalf("rename: %v", err) | ||
| } | ||
| if err := os.WriteFile(logPath, []byte("after rotation\n"), 0644); err != nil { | ||
| t.Fatalf("create new: %v", err) | ||
| } | ||
|
|
||
| lines := drainLines(linesCh, 2*time.Second) | ||
| found := false | ||
| for _, line := range lines { | ||
| if line == "after rotation" { | ||
| found = true | ||
| break | ||
| } | ||
| } | ||
| if !found { | ||
| t.Errorf("expected 'after rotation' after log rotation, got: %v", lines) | ||
| } | ||
| } | ||
|
|
||
| // Verifies that Close() does not block when the follower's reader is stuck waiting for a newline | ||
| // (e.g. file has content without a trailing newline and was rotated). | ||
| func TestFollower_Close_NoDeadlock_BlockedRead(t *testing.T) { | ||
| dir := t.TempDir() | ||
| logPath := filepath.Join(dir, "test.log") | ||
|
|
||
| // No trailing newline, so reader blocks on ReadBytes('\n') | ||
| if err := os.WriteFile(logPath, []byte("no newline at end"), 0644); err != nil { | ||
| t.Fatalf("create file: %v", err) | ||
| } | ||
|
|
||
| f, err := follower.New(logPath, follower.Config{Whence: io.SeekEnd, Offset: 0, Reopen: true}) | ||
| if err != nil { | ||
| t.Fatalf("follower.New: %v", err) | ||
| } | ||
|
|
||
| // Rotate the file so the follower holds an fd to a now-deleted file | ||
| if err := os.Rename(logPath, logPath+".1"); err != nil { | ||
| t.Fatalf("rename: %v", err) | ||
| } | ||
|
|
||
| // Close must return promptly; a deadlock here means the fd leak is present | ||
| done := make(chan struct{}) | ||
| go func() { f.Close(); close(done) }() | ||
|
|
||
| select { | ||
| case <-done: | ||
| case <-time.After(3 * time.Second): | ||
| t.Fatal("Close() blocked") | ||
| } | ||
| } | ||
|
|
||
| // Verifies that Close() returns when the follower's sendLine goroutine is blocked on the unbuffered | ||
| // lines channel. Closing the underlying file unblocks the reader, which lets the goroutine exit. | ||
| func TestFollower_Close_NoDeadlock_SendLineBlocks(t *testing.T) { | ||
| dir := t.TempDir() | ||
| logPath := filepath.Join(dir, "test.log") | ||
|
|
||
| // A complete line will cause sendLine to block on the unbuffered channel | ||
| if err := os.WriteFile(logPath, []byte("line to trigger sendLine\n"), 0644); err != nil { | ||
| t.Fatalf("create file: %v", err) | ||
| } | ||
|
|
||
| f, err := follower.New(logPath, follower.Config{Whence: io.SeekStart, Offset: 0, Reopen: true}) | ||
| if err != nil { | ||
| t.Fatalf("follower.New: %v", err) | ||
| } | ||
|
|
||
| // Intentionally don't read from f.Lines() so sendLine blocks | ||
| done := make(chan struct{}) | ||
| go func() { f.Close(); close(done) }() | ||
|
|
||
| select { | ||
| case <-done: | ||
| case <-time.After(3 * time.Second): | ||
| t.Fatal("Close() blocked - sendLine deadlock not resolved") | ||
| } | ||
| } | ||
|
|
||
| func testLogger() *util.Logger { | ||
| return &util.Logger{ | ||
| Verbose: true, | ||
| Destination: log.New(os.Stderr, "", 0), | ||
| } | ||
| } | ||
|
|
||
| func drainLines(ch chan SelfHostedLogStreamItem, timeout time.Duration) []string { | ||
| var lines []string | ||
| timer := time.NewTimer(timeout) | ||
| defer timer.Stop() | ||
| for { | ||
| select { | ||
| case item, ok := <-ch: | ||
| if !ok { | ||
| return lines | ||
| } | ||
| lines = append(lines, item.Line) | ||
| case <-timer.C: | ||
| return lines | ||
| } | ||
| } | ||
| } |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
Lineschannel could close due to an internal error. In that case we should break out of the loop to free the file descriptor