github.com/tilt-dev/tilt@v0.36.0/internal/watch/notify_test.go (about)

     1  package watch
     2  
     3  import (
     4  	"bytes"
     5  	"context"
     6  	"fmt"
     7  	"os"
     8  	"path/filepath"
     9  	"runtime"
    10  	"strings"
    11  	"testing"
    12  	"time"
    13  
    14  	"github.com/stretchr/testify/assert"
    15  	"github.com/stretchr/testify/require"
    16  
    17  	"github.com/tilt-dev/tilt/internal/dockerignore"
    18  	"github.com/tilt-dev/tilt/internal/testutils/tempdir"
    19  	"github.com/tilt-dev/tilt/pkg/logger"
    20  )
    21  
    22  // Each implementation of the notify interface should have the same basic
    23  // behavior.
    24  
    25  func TestWindowsBufferSize(t *testing.T) {
    26  	t.Run("empty", func(t *testing.T) {
    27  		t.Setenv(WindowsBufferSizeEnvVar, "")
    28  		require.Equal(t, defaultBufferSize, DesiredWindowsBufferSize())
    29  	})
    30  
    31  	t.Run("non-integer", func(t *testing.T) {
    32  		t.Setenv(WindowsBufferSizeEnvVar, "a")
    33  		require.Equal(t, defaultBufferSize, DesiredWindowsBufferSize())
    34  	})
    35  
    36  	t.Run("integer", func(t *testing.T) {
    37  		t.Setenv(WindowsBufferSizeEnvVar, "10")
    38  		require.Equal(t, 10, DesiredWindowsBufferSize())
    39  	})
    40  }
    41  
    42  func TestNoEvents(t *testing.T) {
    43  	f := newNotifyFixture(t)
    44  	f.assertEvents()
    45  }
    46  
    47  func TestNoWatches(t *testing.T) {
    48  	f := newNotifyFixture(t)
    49  	f.paths = nil
    50  	f.rebuildWatcher()
    51  	f.assertEvents()
    52  }
    53  
    54  func TestEventOrdering(t *testing.T) {
    55  	if runtime.GOOS == "windows" {
    56  		// https://qualapps.blogspot.com/2010/05/understanding-readdirectorychangesw_19.html
    57  		t.Skip("Windows doesn't make great guarantees about duplicate/out-of-order events")
    58  		return
    59  	}
    60  	f := newNotifyFixture(t)
    61  
    62  	count := 8
    63  	dirs := make([]string, count)
    64  	for i := range dirs {
    65  		dir := f.TempDir("watched")
    66  		dirs[i] = dir
    67  		f.watch(dir)
    68  	}
    69  
    70  	f.fsync()
    71  	f.events = nil
    72  
    73  	var expected []string
    74  	for i, dir := range dirs {
    75  		base := fmt.Sprintf("%d.txt", i)
    76  		p := filepath.Join(dir, base)
    77  		err := os.WriteFile(p, []byte(base), os.FileMode(0777))
    78  		if err != nil {
    79  			t.Fatal(err)
    80  		}
    81  		expected = append(expected, filepath.Join(dir, base))
    82  	}
    83  
    84  	f.assertEvents(expected...)
    85  }
    86  
    87  // Simulate a git branch switch that creates a bunch
    88  // of directories, creates files in them, then deletes
    89  // them all quickly. Make sure there are no errors.
    90  func TestGitBranchSwitch(t *testing.T) {
    91  	f := newNotifyFixture(t)
    92  
    93  	count := 10
    94  	dirs := make([]string, count)
    95  	for i := range dirs {
    96  		dir := f.TempDir("watched")
    97  		dirs[i] = dir
    98  		f.watch(dir)
    99  	}
   100  
   101  	f.fsync()
   102  	f.events = nil
   103  
   104  	// consume all the events in the background
   105  	ctx, cancel := context.WithCancel(context.Background())
   106  	done := f.consumeEventsInBackground(ctx)
   107  
   108  	for i, dir := range dirs {
   109  		for j := 0; j < count; j++ {
   110  			base := fmt.Sprintf("x/y/dir-%d/x.txt", j)
   111  			p := filepath.Join(dir, base)
   112  			f.WriteFile(p, "contents")
   113  		}
   114  
   115  		if i != 0 {
   116  			err := os.RemoveAll(dir)
   117  			require.NoError(t, err)
   118  		}
   119  	}
   120  
   121  	cancel()
   122  	err := <-done
   123  	if err != nil {
   124  		t.Fatal(err)
   125  	}
   126  
   127  	f.fsync()
   128  	f.events = nil
   129  
   130  	// Make sure the watch on the first dir still works.
   131  	dir := dirs[0]
   132  	path := filepath.Join(dir, "change")
   133  
   134  	f.WriteFile(path, "hello\n")
   135  	f.fsync()
   136  
   137  	f.assertEvents(path)
   138  
   139  	// Make sure there are no errors in the out stream
   140  	assert.Equal(t, "", f.out.String())
   141  }
   142  
   143  func TestWatchesAreRecursive(t *testing.T) {
   144  	f := newNotifyFixture(t)
   145  
   146  	root := f.TempDir("root")
   147  
   148  	// add a sub directory
   149  	subPath := filepath.Join(root, "sub")
   150  	f.MkdirAll(subPath)
   151  
   152  	// watch parent
   153  	f.watch(root)
   154  
   155  	f.fsync()
   156  	f.events = nil
   157  	// change sub directory
   158  	changeFilePath := filepath.Join(subPath, "change")
   159  	f.WriteFile(changeFilePath, "change")
   160  
   161  	f.assertEvents(changeFilePath)
   162  }
   163  
   164  func TestNewDirectoriesAreRecursivelyWatched(t *testing.T) {
   165  	f := newNotifyFixture(t)
   166  
   167  	root := f.TempDir("root")
   168  
   169  	// watch parent
   170  	f.watch(root)
   171  	f.fsync()
   172  	f.events = nil
   173  
   174  	// add a sub directory
   175  	subPath := filepath.Join(root, "sub")
   176  	f.MkdirAll(subPath)
   177  
   178  	// change something inside sub directory
   179  	changeFilePath := filepath.Join(subPath, "change")
   180  	file, err := os.OpenFile(changeFilePath, os.O_RDONLY|os.O_CREATE, 0666)
   181  	if err != nil {
   182  		t.Fatal(err)
   183  	}
   184  	_ = file.Close()
   185  	f.assertEvents(subPath, changeFilePath)
   186  }
   187  
   188  func TestWatchNonExistentPath(t *testing.T) {
   189  	f := newNotifyFixture(t)
   190  
   191  	root := f.TempDir("root")
   192  	path := filepath.Join(root, "change")
   193  
   194  	f.watch(path)
   195  	f.fsync()
   196  
   197  	d1 := "hello\ngo\n"
   198  	f.WriteFile(path, d1)
   199  	f.assertEvents(path)
   200  }
   201  
   202  func TestWatchDirectoryAndTouchIt(t *testing.T) {
   203  	f := newNotifyFixture(t)
   204  
   205  	cTime := time.Now()
   206  	root := f.TempDir("root")
   207  	path := filepath.Join(root, "change")
   208  	a := filepath.Join(path, "a.txt")
   209  	f.WriteFile(a, "a")
   210  
   211  	f.watch(path)
   212  	f.fsync()
   213  
   214  	err := os.Chtimes(path, cTime, time.Now())
   215  	assert.NoError(t, err)
   216  	err = os.Chmod(path, 0770)
   217  	assert.NoError(t, err)
   218  	f.assertEvents()
   219  }
   220  
   221  func TestWatchDirectoryAndTouchSubdir(t *testing.T) {
   222  	f := newNotifyFixture(t)
   223  
   224  	cTime := time.Now()
   225  	root := f.TempDir("root")
   226  	path := filepath.Join(root, "change")
   227  	a := filepath.Join(path, "a.txt")
   228  	f.WriteFile(a, "a")
   229  
   230  	f.watch(root)
   231  	f.fsync()
   232  
   233  	err := os.Chtimes(path, cTime, time.Now().Add(time.Minute))
   234  	assert.NoError(t, err)
   235  
   236  	f.assertEvents()
   237  }
   238  
   239  func TestWatchNonExistentPathDoesNotFireSiblingEvent(t *testing.T) {
   240  	f := newNotifyFixture(t)
   241  
   242  	root := f.TempDir("root")
   243  	watchedFile := filepath.Join(root, "a.txt")
   244  	unwatchedSibling := filepath.Join(root, "b.txt")
   245  
   246  	f.watch(watchedFile)
   247  	f.fsync()
   248  
   249  	d1 := "hello\ngo\n"
   250  	f.WriteFile(unwatchedSibling, d1)
   251  	f.assertEvents()
   252  }
   253  
   254  func TestRemove(t *testing.T) {
   255  	f := newNotifyFixture(t)
   256  
   257  	root := f.TempDir("root")
   258  	path := filepath.Join(root, "change")
   259  
   260  	d1 := "hello\ngo\n"
   261  	f.WriteFile(path, d1)
   262  
   263  	f.watch(path)
   264  	f.fsync()
   265  	f.events = nil
   266  	err := os.Remove(path)
   267  	if err != nil {
   268  		t.Fatal(err)
   269  	}
   270  	f.assertEvents(path)
   271  }
   272  
   273  func TestRemoveAndAddBack(t *testing.T) {
   274  	f := newNotifyFixture(t)
   275  
   276  	path := filepath.Join(f.paths[0], "change")
   277  
   278  	d1 := []byte("hello\ngo\n")
   279  	err := os.WriteFile(path, d1, 0644)
   280  	if err != nil {
   281  		t.Fatal(err)
   282  	}
   283  	f.watch(path)
   284  	f.assertEvents(path)
   285  
   286  	err = os.Remove(path)
   287  	if err != nil {
   288  		t.Fatal(err)
   289  	}
   290  
   291  	f.assertEvents(path)
   292  	f.events = nil
   293  
   294  	err = os.WriteFile(path, d1, 0644)
   295  	if err != nil {
   296  		t.Fatal(err)
   297  	}
   298  
   299  	f.assertEvents(path)
   300  }
   301  
   302  func TestSingleFile(t *testing.T) {
   303  	f := newNotifyFixture(t)
   304  
   305  	root := f.TempDir("root")
   306  	path := filepath.Join(root, "change")
   307  
   308  	d1 := "hello\ngo\n"
   309  	f.WriteFile(path, d1)
   310  
   311  	f.watch(path)
   312  	f.fsync()
   313  
   314  	d2 := []byte("hello\nworld\n")
   315  	err := os.WriteFile(path, d2, 0644)
   316  	if err != nil {
   317  		t.Fatal(err)
   318  	}
   319  	f.assertEvents(path)
   320  }
   321  
   322  func TestWriteBrokenLink(t *testing.T) {
   323  	if runtime.GOOS == "windows" {
   324  		t.Skip("no user-space symlinks on windows")
   325  	}
   326  	f := newNotifyFixture(t)
   327  
   328  	link := filepath.Join(f.paths[0], "brokenLink")
   329  	missingFile := filepath.Join(f.paths[0], "missingFile")
   330  	err := os.Symlink(missingFile, link)
   331  	if err != nil {
   332  		t.Fatal(err)
   333  	}
   334  
   335  	f.assertEvents(link)
   336  }
   337  
   338  func TestWriteGoodLink(t *testing.T) {
   339  	if runtime.GOOS == "windows" {
   340  		t.Skip("no user-space symlinks on windows")
   341  	}
   342  	f := newNotifyFixture(t)
   343  
   344  	goodFile := filepath.Join(f.paths[0], "goodFile")
   345  	err := os.WriteFile(goodFile, []byte("hello"), 0644)
   346  	if err != nil {
   347  		t.Fatal(err)
   348  	}
   349  
   350  	link := filepath.Join(f.paths[0], "goodFileSymlink")
   351  	err = os.Symlink(goodFile, link)
   352  	if err != nil {
   353  		t.Fatal(err)
   354  	}
   355  
   356  	f.assertEvents(goodFile, link)
   357  }
   358  
   359  func TestWatchBrokenLink(t *testing.T) {
   360  	if runtime.GOOS == "windows" {
   361  		t.Skip("no user-space symlinks on windows")
   362  	}
   363  	f := newNotifyFixture(t)
   364  
   365  	newRoot, err := NewDir(t.Name())
   366  	if err != nil {
   367  		t.Fatal(err)
   368  	}
   369  	defer func() {
   370  		err := newRoot.TearDown()
   371  		if err != nil {
   372  			fmt.Printf("error tearing down temp dir: %v\n", err)
   373  		}
   374  	}()
   375  
   376  	link := filepath.Join(newRoot.Path(), "brokenLink")
   377  	missingFile := filepath.Join(newRoot.Path(), "missingFile")
   378  	err = os.Symlink(missingFile, link)
   379  	if err != nil {
   380  		t.Fatal(err)
   381  	}
   382  
   383  	f.watch(newRoot.Path())
   384  	err = os.Remove(link)
   385  	require.NoError(t, err)
   386  	f.assertEvents(link)
   387  }
   388  
   389  func TestMoveAndReplace(t *testing.T) {
   390  	f := newNotifyFixture(t)
   391  
   392  	root := f.TempDir("root")
   393  	file := filepath.Join(root, "myfile")
   394  	f.WriteFile(file, "hello")
   395  
   396  	f.watch(file)
   397  	tmpFile := filepath.Join(root, ".myfile.swp")
   398  	f.WriteFile(tmpFile, "world")
   399  
   400  	err := os.Rename(tmpFile, file)
   401  	if err != nil {
   402  		t.Fatal(err)
   403  	}
   404  
   405  	f.assertEvents(file)
   406  }
   407  
   408  func TestWatchBothDirAndFile(t *testing.T) {
   409  	f := newNotifyFixture(t)
   410  
   411  	dir := f.JoinPath("foo")
   412  	fileA := f.JoinPath("foo", "a")
   413  	fileB := f.JoinPath("foo", "b")
   414  	f.WriteFile(fileA, "a")
   415  	f.WriteFile(fileB, "b")
   416  
   417  	f.watch(fileA)
   418  	f.watch(dir)
   419  	f.fsync()
   420  	f.events = nil
   421  
   422  	f.WriteFile(fileB, "b-new")
   423  	f.assertEvents(fileB)
   424  }
   425  
   426  func TestWatchNonexistentFileInNonexistentDirectoryCreatedSimultaneously(t *testing.T) {
   427  	f := newNotifyFixture(t)
   428  
   429  	root := f.JoinPath("root")
   430  	err := os.Mkdir(root, 0777)
   431  	if err != nil {
   432  		t.Fatal(err)
   433  	}
   434  	file := f.JoinPath("root", "parent", "a")
   435  
   436  	f.watch(file)
   437  	f.fsync()
   438  	f.events = nil
   439  	f.WriteFile(file, "hello")
   440  	f.assertEvents(file)
   441  }
   442  
   443  func TestWatchNonexistentDirectory(t *testing.T) {
   444  	f := newNotifyFixture(t)
   445  
   446  	root := f.JoinPath("root")
   447  	err := os.Mkdir(root, 0777)
   448  	if err != nil {
   449  		t.Fatal(err)
   450  	}
   451  	parent := f.JoinPath("parent")
   452  	file := f.JoinPath("parent", "a")
   453  
   454  	f.watch(parent)
   455  	f.fsync()
   456  	f.events = nil
   457  
   458  	err = os.Mkdir(parent, 0777)
   459  	if err != nil {
   460  		t.Fatal(err)
   461  	}
   462  
   463  	// for directories that were the root of an Add, we don't report creation, cf. watcher_darwin.go
   464  	f.assertEvents()
   465  
   466  	f.events = nil
   467  	f.WriteFile(file, "hello")
   468  
   469  	f.assertEvents(file)
   470  }
   471  
   472  func TestWatchNonexistentFileInNonexistentDirectory(t *testing.T) {
   473  	f := newNotifyFixture(t)
   474  
   475  	root := f.JoinPath("root")
   476  	err := os.Mkdir(root, 0777)
   477  	if err != nil {
   478  		t.Fatal(err)
   479  	}
   480  	parent := f.JoinPath("parent")
   481  	file := f.JoinPath("parent", "a")
   482  
   483  	f.watch(file)
   484  	f.assertEvents()
   485  
   486  	err = os.Mkdir(parent, 0777)
   487  	if err != nil {
   488  		t.Fatal(err)
   489  	}
   490  
   491  	f.assertEvents()
   492  	f.WriteFile(file, "hello")
   493  	f.assertEvents(file)
   494  }
   495  
   496  func TestWatchCountInnerFile(t *testing.T) {
   497  	f := newNotifyFixture(t)
   498  
   499  	root := f.paths[0]
   500  	a := f.JoinPath(root, "a")
   501  	b := f.JoinPath(a, "b")
   502  	file := f.JoinPath(b, "bigFile")
   503  	f.WriteFile(file, "hello")
   504  	f.assertEvents(a, b, file)
   505  
   506  	expectedWatches := 3
   507  	if isRecursiveWatcher() {
   508  		expectedWatches = 1
   509  	}
   510  	assert.Equal(t, expectedWatches, int(numberOfWatches.Value()))
   511  }
   512  
   513  func TestWatchCountInnerFileWithIgnore(t *testing.T) {
   514  	f := newNotifyFixture(t)
   515  
   516  	root := f.paths[0]
   517  	ignore, _ := dockerignore.NewDockerPatternMatcher(root, []string{
   518  		"a",
   519  		"!a/b",
   520  	})
   521  	f.setIgnore(ignore)
   522  
   523  	a := f.JoinPath(root, "a")
   524  	b := f.JoinPath(a, "b")
   525  	file := f.JoinPath(b, "bigFile")
   526  	f.WriteFile(file, "hello")
   527  	f.assertEvents(b, file)
   528  
   529  	expectedWatches := 3
   530  	if isRecursiveWatcher() {
   531  		expectedWatches = 1
   532  	}
   533  	assert.Equal(t, expectedWatches, int(numberOfWatches.Value()))
   534  }
   535  
   536  func TestIgnoreCreatedDir(t *testing.T) {
   537  	f := newNotifyFixture(t)
   538  
   539  	root := f.paths[0]
   540  	ignore, _ := dockerignore.NewDockerPatternMatcher(root, []string{"a/b"})
   541  	f.setIgnore(ignore)
   542  
   543  	a := f.JoinPath(root, "a")
   544  	b := f.JoinPath(a, "b")
   545  	file := f.JoinPath(b, "bigFile")
   546  	f.WriteFile(file, "hello")
   547  	f.assertEvents(a)
   548  
   549  	expectedWatches := 2
   550  	if isRecursiveWatcher() {
   551  		expectedWatches = 1
   552  	}
   553  	assert.Equal(t, expectedWatches, int(numberOfWatches.Value()))
   554  }
   555  
   556  func TestIgnoreCreatedDirWithExclusions(t *testing.T) {
   557  	f := newNotifyFixture(t)
   558  
   559  	root := f.paths[0]
   560  	ignore, _ := dockerignore.NewDockerPatternMatcher(root,
   561  		[]string{
   562  			"a/b",
   563  			"c",
   564  			"!c/d",
   565  		})
   566  	f.setIgnore(ignore)
   567  
   568  	a := f.JoinPath(root, "a")
   569  	b := f.JoinPath(a, "b")
   570  	file := f.JoinPath(b, "bigFile")
   571  	f.WriteFile(file, "hello")
   572  	f.assertEvents(a)
   573  
   574  	expectedWatches := 2
   575  	if isRecursiveWatcher() {
   576  		expectedWatches = 1
   577  	}
   578  	assert.Equal(t, expectedWatches, int(numberOfWatches.Value()))
   579  }
   580  
   581  func TestIgnoreInitialDir(t *testing.T) {
   582  	f := newNotifyFixture(t)
   583  
   584  	root := f.TempDir("root")
   585  	ignore, _ := dockerignore.NewDockerPatternMatcher(root, []string{"a/b"})
   586  	f.setIgnore(ignore)
   587  
   588  	a := f.JoinPath(root, "a")
   589  	b := f.JoinPath(a, "b")
   590  	file := f.JoinPath(b, "bigFile")
   591  	f.WriteFile(file, "hello")
   592  	f.watch(root)
   593  
   594  	f.assertEvents()
   595  
   596  	expectedWatches := 3
   597  	if isRecursiveWatcher() {
   598  		expectedWatches = 2
   599  	}
   600  	assert.Equal(t, expectedWatches, int(numberOfWatches.Value()))
   601  }
   602  
   603  func isRecursiveWatcher() bool {
   604  	return runtime.GOOS == "darwin" || runtime.GOOS == "windows"
   605  }
   606  
   607  type notifyFixture struct {
   608  	ctx    context.Context
   609  	cancel func()
   610  	out    *bytes.Buffer
   611  	*tempdir.TempDirFixture
   612  	notify Notify
   613  	ignore PathMatcher
   614  	paths  []string
   615  	events []FileEvent
   616  }
   617  
   618  func newNotifyFixture(t *testing.T) *notifyFixture {
   619  	out := bytes.NewBuffer(nil)
   620  	ctx, cancel := context.WithCancel(context.Background())
   621  	nf := &notifyFixture{
   622  		ctx:            ctx,
   623  		cancel:         cancel,
   624  		TempDirFixture: tempdir.NewTempDirFixture(t),
   625  		paths:          []string{},
   626  		ignore:         EmptyMatcher{},
   627  		out:            out,
   628  	}
   629  	nf.watch(nf.TempDir("watched"))
   630  	t.Cleanup(nf.tearDown)
   631  	return nf
   632  }
   633  
   634  func (f *notifyFixture) setIgnore(ignore PathMatcher) {
   635  	f.ignore = ignore
   636  	f.rebuildWatcher()
   637  }
   638  
   639  func (f *notifyFixture) watch(path string) {
   640  	f.paths = append(f.paths, path)
   641  	f.rebuildWatcher()
   642  }
   643  
   644  func (f *notifyFixture) rebuildWatcher() {
   645  	// sync any outstanding events and close the old watcher
   646  	if f.notify != nil {
   647  		f.fsync()
   648  		f.closeWatcher()
   649  	}
   650  
   651  	// create a new watcher
   652  	notify, err := NewWatcher(f.paths, f.ignore, logger.NewTestLogger(f.out))
   653  	if err != nil {
   654  		f.T().Fatal(err)
   655  	}
   656  	f.notify = notify
   657  	err = f.notify.Start()
   658  	if err != nil {
   659  		f.T().Fatal(err)
   660  	}
   661  }
   662  
   663  func (f *notifyFixture) assertEvents(expected ...string) {
   664  	f.fsync()
   665  	if runtime.GOOS == "windows" {
   666  		// NOTE(nick): It's unclear to me why an extra fsync() helps
   667  		// here, but it makes the I/O way more predictable.
   668  		f.fsync()
   669  	}
   670  
   671  	if len(f.events) != len(expected) {
   672  		f.T().Fatalf("Got %d events (expected %d): %v %v", len(f.events), len(expected), f.events, expected)
   673  	}
   674  
   675  	for i, actual := range f.events {
   676  		e := FileEvent{expected[i]}
   677  		if actual != e {
   678  			f.T().Fatalf("Got event %v (expected %v)", actual, e)
   679  		}
   680  	}
   681  }
   682  
   683  func (f *notifyFixture) consumeEventsInBackground(ctx context.Context) chan error {
   684  	done := make(chan error)
   685  	go func() {
   686  		for {
   687  			select {
   688  			case <-f.ctx.Done():
   689  				close(done)
   690  				return
   691  			case <-ctx.Done():
   692  				close(done)
   693  				return
   694  			case err := <-f.notify.Errors():
   695  				done <- err
   696  				close(done)
   697  				return
   698  			case <-f.notify.Events():
   699  			}
   700  		}
   701  	}()
   702  	return done
   703  }
   704  
   705  func (f *notifyFixture) fsync() {
   706  	f.fsyncWithRetryCount(3)
   707  }
   708  
   709  func (f *notifyFixture) fsyncWithRetryCount(retryCount int) {
   710  	if len(f.paths) == 0 {
   711  		return
   712  	}
   713  
   714  	syncPathBase := fmt.Sprintf("sync-%d.txt", time.Now().UnixNano())
   715  	syncPath := filepath.Join(f.paths[0], syncPathBase)
   716  	anySyncPath := filepath.Join(f.paths[0], "sync-")
   717  	timeout := time.After(250 * time.Millisecond)
   718  
   719  	f.WriteFile(syncPath, time.Now().String())
   720  
   721  F:
   722  	for {
   723  		select {
   724  		case <-f.ctx.Done():
   725  			return
   726  		case err := <-f.notify.Errors():
   727  			f.T().Fatal(err)
   728  
   729  		case event := <-f.notify.Events():
   730  			if strings.Contains(event.Path(), syncPath) {
   731  				break F
   732  			}
   733  			if strings.Contains(event.Path(), anySyncPath) {
   734  				continue
   735  			}
   736  
   737  			// Don't bother tracking duplicate changes to the same path
   738  			// for testing.
   739  			if len(f.events) > 0 && f.events[len(f.events)-1].Path() == event.Path() {
   740  				continue
   741  			}
   742  
   743  			f.events = append(f.events, event)
   744  
   745  		case <-timeout:
   746  			if retryCount <= 0 {
   747  				f.T().Fatalf("fsync: timeout")
   748  			} else {
   749  				f.fsyncWithRetryCount(retryCount - 1)
   750  			}
   751  			return
   752  		}
   753  	}
   754  }
   755  
   756  func (f *notifyFixture) closeWatcher() {
   757  	notify := f.notify
   758  	err := notify.Close()
   759  	if err != nil {
   760  		f.T().Fatal(err)
   761  	}
   762  
   763  	// drain channels from watcher
   764  	go func() {
   765  		for range notify.Events() {
   766  		}
   767  	}()
   768  
   769  	go func() {
   770  		for range notify.Errors() {
   771  		}
   772  	}()
   773  }
   774  
   775  func (f *notifyFixture) tearDown() {
   776  	f.cancel()
   777  	f.closeWatcher()
   778  	numberOfWatches.Set(0)
   779  }