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 := ¬ifyFixture{ 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 }