compose/pkg/watch/notify_test.go

755 lines
15 KiB
Go

/*
Copyright 2020 Docker Compose CLI authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package watch
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Each implementation of the notify interface should have the same basic
// behavior.
func TestWindowsBufferSize(t *testing.T) {
orig := os.Getenv(WindowsBufferSizeEnvVar)
defer os.Setenv(WindowsBufferSizeEnvVar, orig) //nolint:errcheck
err := os.Setenv(WindowsBufferSizeEnvVar, "")
assert.Nil(t, err)
assert.Equal(t, defaultBufferSize, DesiredWindowsBufferSize())
err = os.Setenv(WindowsBufferSizeEnvVar, "a")
assert.Nil(t, err)
assert.Equal(t, defaultBufferSize, DesiredWindowsBufferSize())
err = os.Setenv(WindowsBufferSizeEnvVar, "10")
assert.Nil(t, err)
assert.Equal(t, 10, DesiredWindowsBufferSize())
}
func TestNoEvents(t *testing.T) {
f := newNotifyFixture(t)
f.assertEvents()
}
func TestNoWatches(t *testing.T) {
f := newNotifyFixture(t)
f.paths = nil
f.rebuildWatcher()
f.assertEvents()
}
func TestEventOrdering(t *testing.T) {
if runtime.GOOS == "windows" {
// https://qualapps.blogspot.com/2010/05/understanding-readdirectorychangesw_19.html
t.Skip("Windows doesn't make great guarantees about duplicate/out-of-order events")
return
}
f := newNotifyFixture(t)
count := 8
dirs := make([]string, count)
for i := range dirs {
dir := f.TempDir("watched")
dirs[i] = dir
f.watch(dir)
}
f.fsync()
f.events = nil
var expected []string
for i, dir := range dirs {
base := fmt.Sprintf("%d.txt", i)
p := filepath.Join(dir, base)
err := os.WriteFile(p, []byte(base), os.FileMode(0o777))
if err != nil {
t.Fatal(err)
}
expected = append(expected, filepath.Join(dir, base))
}
f.assertEvents(expected...)
}
// Simulate a git branch switch that creates a bunch
// of directories, creates files in them, then deletes
// them all quickly. Make sure there are no errors.
func TestGitBranchSwitch(t *testing.T) {
f := newNotifyFixture(t)
count := 10
dirs := make([]string, count)
for i := range dirs {
dir := f.TempDir("watched")
dirs[i] = dir
f.watch(dir)
}
f.fsync()
f.events = nil
// consume all the events in the background
ctx, cancel := context.WithCancel(context.Background())
done := f.consumeEventsInBackground(ctx)
for i, dir := range dirs {
for j := 0; j < count; j++ {
base := fmt.Sprintf("x/y/dir-%d/x.txt", j)
p := filepath.Join(dir, base)
f.WriteFile(p, "contents")
}
if i != 0 {
err := os.RemoveAll(dir)
require.NoError(t, err)
}
}
cancel()
err := <-done
if err != nil {
t.Fatal(err)
}
f.fsync()
f.events = nil
// Make sure the watch on the first dir still works.
dir := dirs[0]
path := filepath.Join(dir, "change")
f.WriteFile(path, "hello\n")
f.fsync()
f.assertEvents(path)
// Make sure there are no errors in the out stream
assert.Equal(t, "", f.out.String())
}
func TestWatchesAreRecursive(t *testing.T) {
f := newNotifyFixture(t)
root := f.TempDir("root")
// add a sub directory
subPath := filepath.Join(root, "sub")
f.MkdirAll(subPath)
// watch parent
f.watch(root)
f.fsync()
f.events = nil
// change sub directory
changeFilePath := filepath.Join(subPath, "change")
f.WriteFile(changeFilePath, "change")
f.assertEvents(changeFilePath)
}
func TestNewDirectoriesAreRecursivelyWatched(t *testing.T) {
f := newNotifyFixture(t)
root := f.TempDir("root")
// watch parent
f.watch(root)
f.fsync()
f.events = nil
// add a sub directory
subPath := filepath.Join(root, "sub")
f.MkdirAll(subPath)
// change something inside sub directory
changeFilePath := filepath.Join(subPath, "change")
file, err := os.OpenFile(changeFilePath, os.O_RDONLY|os.O_CREATE, 0o666)
if err != nil {
t.Fatal(err)
}
_ = file.Close()
f.assertEvents(subPath, changeFilePath)
}
func TestWatchNonExistentPath(t *testing.T) {
f := newNotifyFixture(t)
root := f.TempDir("root")
path := filepath.Join(root, "change")
f.watch(path)
f.fsync()
d1 := "hello\ngo\n"
f.WriteFile(path, d1)
f.assertEvents(path)
}
func TestWatchNonExistentPathDoesNotFireSiblingEvent(t *testing.T) {
f := newNotifyFixture(t)
root := f.TempDir("root")
watchedFile := filepath.Join(root, "a.txt")
unwatchedSibling := filepath.Join(root, "b.txt")
f.watch(watchedFile)
f.fsync()
d1 := "hello\ngo\n"
f.WriteFile(unwatchedSibling, d1)
f.assertEvents()
}
func TestRemove(t *testing.T) {
f := newNotifyFixture(t)
root := f.TempDir("root")
path := filepath.Join(root, "change")
d1 := "hello\ngo\n"
f.WriteFile(path, d1)
f.watch(path)
f.fsync()
f.events = nil
err := os.Remove(path)
if err != nil {
t.Fatal(err)
}
f.assertEvents(path)
}
func TestRemoveAndAddBack(t *testing.T) {
f := newNotifyFixture(t)
path := filepath.Join(f.paths[0], "change")
d1 := []byte("hello\ngo\n")
err := os.WriteFile(path, d1, 0o644)
if err != nil {
t.Fatal(err)
}
f.watch(path)
f.assertEvents(path)
err = os.Remove(path)
if err != nil {
t.Fatal(err)
}
f.assertEvents(path)
f.events = nil
err = os.WriteFile(path, d1, 0o644)
if err != nil {
t.Fatal(err)
}
f.assertEvents(path)
}
func TestSingleFile(t *testing.T) {
f := newNotifyFixture(t)
root := f.TempDir("root")
path := filepath.Join(root, "change")
d1 := "hello\ngo\n"
f.WriteFile(path, d1)
f.watch(path)
f.fsync()
d2 := []byte("hello\nworld\n")
err := os.WriteFile(path, d2, 0o644)
if err != nil {
t.Fatal(err)
}
f.assertEvents(path)
}
func TestWriteBrokenLink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no user-space symlinks on windows")
}
f := newNotifyFixture(t)
link := filepath.Join(f.paths[0], "brokenLink")
missingFile := filepath.Join(f.paths[0], "missingFile")
err := os.Symlink(missingFile, link)
if err != nil {
t.Fatal(err)
}
f.assertEvents(link)
}
func TestWriteGoodLink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no user-space symlinks on windows")
}
f := newNotifyFixture(t)
goodFile := filepath.Join(f.paths[0], "goodFile")
err := os.WriteFile(goodFile, []byte("hello"), 0o644)
if err != nil {
t.Fatal(err)
}
link := filepath.Join(f.paths[0], "goodFileSymlink")
err = os.Symlink(goodFile, link)
if err != nil {
t.Fatal(err)
}
f.assertEvents(goodFile, link)
}
func TestWatchBrokenLink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("no user-space symlinks on windows")
}
f := newNotifyFixture(t)
newRoot, err := NewDir(t.Name())
if err != nil {
t.Fatal(err)
}
defer func() {
err := newRoot.TearDown()
if err != nil {
fmt.Printf("error tearing down temp dir: %v\n", err)
}
}()
link := filepath.Join(newRoot.Path(), "brokenLink")
missingFile := filepath.Join(newRoot.Path(), "missingFile")
err = os.Symlink(missingFile, link)
if err != nil {
t.Fatal(err)
}
f.watch(newRoot.Path())
err = os.Remove(link)
require.NoError(t, err)
f.assertEvents(link)
}
func TestMoveAndReplace(t *testing.T) {
f := newNotifyFixture(t)
root := f.TempDir("root")
file := filepath.Join(root, "myfile")
f.WriteFile(file, "hello")
f.watch(file)
tmpFile := filepath.Join(root, ".myfile.swp")
f.WriteFile(tmpFile, "world")
err := os.Rename(tmpFile, file)
if err != nil {
t.Fatal(err)
}
f.assertEvents(file)
}
func TestWatchBothDirAndFile(t *testing.T) {
f := newNotifyFixture(t)
dir := f.JoinPath("foo")
fileA := f.JoinPath("foo", "a")
fileB := f.JoinPath("foo", "b")
f.WriteFile(fileA, "a")
f.WriteFile(fileB, "b")
f.watch(fileA)
f.watch(dir)
f.fsync()
f.events = nil
f.WriteFile(fileB, "b-new")
f.assertEvents(fileB)
}
func TestWatchNonexistentFileInNonexistentDirectoryCreatedSimultaneously(t *testing.T) {
f := newNotifyFixture(t)
root := f.JoinPath("root")
err := os.Mkdir(root, 0o777)
if err != nil {
t.Fatal(err)
}
file := f.JoinPath("root", "parent", "a")
f.watch(file)
f.fsync()
f.events = nil
f.WriteFile(file, "hello")
f.assertEvents(file)
}
func TestWatchNonexistentDirectory(t *testing.T) {
f := newNotifyFixture(t)
root := f.JoinPath("root")
err := os.Mkdir(root, 0o777)
if err != nil {
t.Fatal(err)
}
parent := f.JoinPath("parent")
file := f.JoinPath("parent", "a")
f.watch(parent)
f.fsync()
f.events = nil
err = os.Mkdir(parent, 0o777)
if err != nil {
t.Fatal(err)
}
// for directories that were the root of an Add, we don't report creation, cf. watcher_darwin.go
f.assertEvents()
f.events = nil
f.WriteFile(file, "hello")
f.assertEvents(file)
}
func TestWatchNonexistentFileInNonexistentDirectory(t *testing.T) {
f := newNotifyFixture(t)
root := f.JoinPath("root")
err := os.Mkdir(root, 0o777)
if err != nil {
t.Fatal(err)
}
parent := f.JoinPath("parent")
file := f.JoinPath("parent", "a")
f.watch(file)
f.assertEvents()
err = os.Mkdir(parent, 0o777)
if err != nil {
t.Fatal(err)
}
f.assertEvents()
f.WriteFile(file, "hello")
f.assertEvents(file)
}
func TestWatchCountInnerFile(t *testing.T) {
f := newNotifyFixture(t)
root := f.paths[0]
a := f.JoinPath(root, "a")
b := f.JoinPath(a, "b")
file := f.JoinPath(b, "bigFile")
f.WriteFile(file, "hello")
f.assertEvents(a, b, file)
expectedWatches := 3
if isRecursiveWatcher() {
expectedWatches = 1
}
assert.Equal(t, expectedWatches, int(numberOfWatches.Value()))
}
func TestWatchCountInnerFileWithIgnore(t *testing.T) {
f := newNotifyFixture(t)
root := f.paths[0]
ignore, _ := NewDockerPatternMatcher(root, []string{
"a",
"!a/b",
})
f.setIgnore(ignore)
a := f.JoinPath(root, "a")
b := f.JoinPath(a, "b")
file := f.JoinPath(b, "bigFile")
f.WriteFile(file, "hello")
f.assertEvents(b, file)
expectedWatches := 3
if isRecursiveWatcher() {
expectedWatches = 1
}
assert.Equal(t, expectedWatches, int(numberOfWatches.Value()))
}
func TestIgnoreCreatedDir(t *testing.T) {
f := newNotifyFixture(t)
root := f.paths[0]
ignore, _ := NewDockerPatternMatcher(root, []string{"a/b"})
f.setIgnore(ignore)
a := f.JoinPath(root, "a")
b := f.JoinPath(a, "b")
file := f.JoinPath(b, "bigFile")
f.WriteFile(file, "hello")
f.assertEvents(a)
expectedWatches := 2
if isRecursiveWatcher() {
expectedWatches = 1
}
assert.Equal(t, expectedWatches, int(numberOfWatches.Value()))
}
func TestIgnoreCreatedDirWithExclusions(t *testing.T) {
f := newNotifyFixture(t)
root := f.paths[0]
ignore, _ := NewDockerPatternMatcher(root,
[]string{
"a/b",
"c",
"!c/d",
})
f.setIgnore(ignore)
a := f.JoinPath(root, "a")
b := f.JoinPath(a, "b")
file := f.JoinPath(b, "bigFile")
f.WriteFile(file, "hello")
f.assertEvents(a)
expectedWatches := 2
if isRecursiveWatcher() {
expectedWatches = 1
}
assert.Equal(t, expectedWatches, int(numberOfWatches.Value()))
}
func TestIgnoreInitialDir(t *testing.T) {
f := newNotifyFixture(t)
root := f.TempDir("root")
ignore, _ := NewDockerPatternMatcher(root, []string{"a/b"})
f.setIgnore(ignore)
a := f.JoinPath(root, "a")
b := f.JoinPath(a, "b")
file := f.JoinPath(b, "bigFile")
f.WriteFile(file, "hello")
f.watch(root)
f.assertEvents()
expectedWatches := 3
if isRecursiveWatcher() {
expectedWatches = 2
}
assert.Equal(t, expectedWatches, int(numberOfWatches.Value()))
}
func isRecursiveWatcher() bool {
return runtime.GOOS == "darwin" || runtime.GOOS == "windows"
}
type notifyFixture struct {
ctx context.Context
cancel func()
out *bytes.Buffer
*TempDirFixture
notify Notify
ignore PathMatcher
paths []string
events []FileEvent
}
func newNotifyFixture(t *testing.T) *notifyFixture {
out := bytes.NewBuffer(nil)
ctx, cancel := context.WithCancel(context.Background())
nf := &notifyFixture{
ctx: ctx,
cancel: cancel,
TempDirFixture: NewTempDirFixture(t),
paths: []string{},
ignore: EmptyMatcher{},
out: out,
}
nf.watch(nf.TempDir("watched"))
t.Cleanup(nf.tearDown)
return nf
}
func (f *notifyFixture) setIgnore(ignore PathMatcher) {
f.ignore = ignore
f.rebuildWatcher()
}
func (f *notifyFixture) watch(path string) {
f.paths = append(f.paths, path)
f.rebuildWatcher()
}
func (f *notifyFixture) rebuildWatcher() {
// sync any outstanding events and close the old watcher
if f.notify != nil {
f.fsync()
f.closeWatcher()
}
// create a new watcher
notify, err := NewWatcher(f.paths, f.ignore)
if err != nil {
f.T().Fatal(err)
}
f.notify = notify
err = f.notify.Start()
if err != nil {
f.T().Fatal(err)
}
}
func (f *notifyFixture) assertEvents(expected ...string) {
f.fsync()
if runtime.GOOS == "windows" {
// NOTE(nick): It's unclear to me why an extra fsync() helps
// here, but it makes the I/O way more predictable.
f.fsync()
}
if len(f.events) != len(expected) {
f.T().Fatalf("Got %d events (expected %d): %v %v", len(f.events), len(expected), f.events, expected)
}
for i, actual := range f.events {
e := FileEvent{expected[i]}
if actual != e {
f.T().Fatalf("Got event %v (expected %v)", actual, e)
}
}
}
func (f *notifyFixture) consumeEventsInBackground(ctx context.Context) chan error {
done := make(chan error)
go func() {
for {
select {
case <-f.ctx.Done():
close(done)
return
case <-ctx.Done():
close(done)
return
case err := <-f.notify.Errors():
done <- err
close(done)
return
case <-f.notify.Events():
}
}
}()
return done
}
func (f *notifyFixture) fsync() {
f.fsyncWithRetryCount(3)
}
func (f *notifyFixture) fsyncWithRetryCount(retryCount int) {
if len(f.paths) == 0 {
return
}
syncPathBase := fmt.Sprintf("sync-%d.txt", time.Now().UnixNano())
syncPath := filepath.Join(f.paths[0], syncPathBase)
anySyncPath := filepath.Join(f.paths[0], "sync-")
timeout := time.After(250 * time.Second)
f.WriteFile(syncPath, time.Now().String())
F:
for {
select {
case <-f.ctx.Done():
return
case err := <-f.notify.Errors():
f.T().Fatal(err)
case event := <-f.notify.Events():
if strings.Contains(event.Path(), syncPath) {
break F
}
if strings.Contains(event.Path(), anySyncPath) {
continue
}
// Don't bother tracking duplicate changes to the same path
// for testing.
if len(f.events) > 0 && f.events[len(f.events)-1].Path() == event.Path() {
continue
}
f.events = append(f.events, event)
case <-timeout:
if retryCount <= 0 {
f.T().Fatalf("fsync: timeout")
} else {
f.fsyncWithRetryCount(retryCount - 1)
}
return
}
}
}
func (f *notifyFixture) closeWatcher() {
notify := f.notify
err := notify.Close()
if err != nil {
f.T().Fatal(err)
}
// drain channels from watcher
go func() {
for range notify.Events() {
}
}()
go func() {
for range notify.Errors() {
}
}()
}
func (f *notifyFixture) tearDown() {
f.cancel()
f.closeWatcher()
numberOfWatches.Set(0)
}