github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/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 TestWatchNonExistentPathDoesNotFireSiblingEvent(t *testing.T) { 222 f := newNotifyFixture(t) 223 224 root := f.TempDir("root") 225 watchedFile := filepath.Join(root, "a.txt") 226 unwatchedSibling := filepath.Join(root, "b.txt") 227 228 f.watch(watchedFile) 229 f.fsync() 230 231 d1 := "hello\ngo\n" 232 f.WriteFile(unwatchedSibling, d1) 233 f.assertEvents() 234 } 235 236 func TestRemove(t *testing.T) { 237 f := newNotifyFixture(t) 238 239 root := f.TempDir("root") 240 path := filepath.Join(root, "change") 241 242 d1 := "hello\ngo\n" 243 f.WriteFile(path, d1) 244 245 f.watch(path) 246 f.fsync() 247 f.events = nil 248 err := os.Remove(path) 249 if err != nil { 250 t.Fatal(err) 251 } 252 f.assertEvents(path) 253 } 254 255 func TestRemoveAndAddBack(t *testing.T) { 256 f := newNotifyFixture(t) 257 258 path := filepath.Join(f.paths[0], "change") 259 260 d1 := []byte("hello\ngo\n") 261 err := os.WriteFile(path, d1, 0644) 262 if err != nil { 263 t.Fatal(err) 264 } 265 f.watch(path) 266 f.assertEvents(path) 267 268 err = os.Remove(path) 269 if err != nil { 270 t.Fatal(err) 271 } 272 273 f.assertEvents(path) 274 f.events = nil 275 276 err = os.WriteFile(path, d1, 0644) 277 if err != nil { 278 t.Fatal(err) 279 } 280 281 f.assertEvents(path) 282 } 283 284 func TestSingleFile(t *testing.T) { 285 f := newNotifyFixture(t) 286 287 root := f.TempDir("root") 288 path := filepath.Join(root, "change") 289 290 d1 := "hello\ngo\n" 291 f.WriteFile(path, d1) 292 293 f.watch(path) 294 f.fsync() 295 296 d2 := []byte("hello\nworld\n") 297 err := os.WriteFile(path, d2, 0644) 298 if err != nil { 299 t.Fatal(err) 300 } 301 f.assertEvents(path) 302 } 303 304 func TestWriteBrokenLink(t *testing.T) { 305 if runtime.GOOS == "windows" { 306 t.Skip("no user-space symlinks on windows") 307 } 308 f := newNotifyFixture(t) 309 310 link := filepath.Join(f.paths[0], "brokenLink") 311 missingFile := filepath.Join(f.paths[0], "missingFile") 312 err := os.Symlink(missingFile, link) 313 if err != nil { 314 t.Fatal(err) 315 } 316 317 f.assertEvents(link) 318 } 319 320 func TestWriteGoodLink(t *testing.T) { 321 if runtime.GOOS == "windows" { 322 t.Skip("no user-space symlinks on windows") 323 } 324 f := newNotifyFixture(t) 325 326 goodFile := filepath.Join(f.paths[0], "goodFile") 327 err := os.WriteFile(goodFile, []byte("hello"), 0644) 328 if err != nil { 329 t.Fatal(err) 330 } 331 332 link := filepath.Join(f.paths[0], "goodFileSymlink") 333 err = os.Symlink(goodFile, link) 334 if err != nil { 335 t.Fatal(err) 336 } 337 338 f.assertEvents(goodFile, link) 339 } 340 341 func TestWatchBrokenLink(t *testing.T) { 342 if runtime.GOOS == "windows" { 343 t.Skip("no user-space symlinks on windows") 344 } 345 f := newNotifyFixture(t) 346 347 newRoot, err := NewDir(t.Name()) 348 if err != nil { 349 t.Fatal(err) 350 } 351 defer func() { 352 err := newRoot.TearDown() 353 if err != nil { 354 fmt.Printf("error tearing down temp dir: %v\n", err) 355 } 356 }() 357 358 link := filepath.Join(newRoot.Path(), "brokenLink") 359 missingFile := filepath.Join(newRoot.Path(), "missingFile") 360 err = os.Symlink(missingFile, link) 361 if err != nil { 362 t.Fatal(err) 363 } 364 365 f.watch(newRoot.Path()) 366 err = os.Remove(link) 367 require.NoError(t, err) 368 f.assertEvents(link) 369 } 370 371 func TestMoveAndReplace(t *testing.T) { 372 f := newNotifyFixture(t) 373 374 root := f.TempDir("root") 375 file := filepath.Join(root, "myfile") 376 f.WriteFile(file, "hello") 377 378 f.watch(file) 379 tmpFile := filepath.Join(root, ".myfile.swp") 380 f.WriteFile(tmpFile, "world") 381 382 err := os.Rename(tmpFile, file) 383 if err != nil { 384 t.Fatal(err) 385 } 386 387 f.assertEvents(file) 388 } 389 390 func TestWatchBothDirAndFile(t *testing.T) { 391 f := newNotifyFixture(t) 392 393 dir := f.JoinPath("foo") 394 fileA := f.JoinPath("foo", "a") 395 fileB := f.JoinPath("foo", "b") 396 f.WriteFile(fileA, "a") 397 f.WriteFile(fileB, "b") 398 399 f.watch(fileA) 400 f.watch(dir) 401 f.fsync() 402 f.events = nil 403 404 f.WriteFile(fileB, "b-new") 405 f.assertEvents(fileB) 406 } 407 408 func TestWatchNonexistentFileInNonexistentDirectoryCreatedSimultaneously(t *testing.T) { 409 f := newNotifyFixture(t) 410 411 root := f.JoinPath("root") 412 err := os.Mkdir(root, 0777) 413 if err != nil { 414 t.Fatal(err) 415 } 416 file := f.JoinPath("root", "parent", "a") 417 418 f.watch(file) 419 f.fsync() 420 f.events = nil 421 f.WriteFile(file, "hello") 422 f.assertEvents(file) 423 } 424 425 func TestWatchNonexistentDirectory(t *testing.T) { 426 f := newNotifyFixture(t) 427 428 root := f.JoinPath("root") 429 err := os.Mkdir(root, 0777) 430 if err != nil { 431 t.Fatal(err) 432 } 433 parent := f.JoinPath("parent") 434 file := f.JoinPath("parent", "a") 435 436 f.watch(parent) 437 f.fsync() 438 f.events = nil 439 440 err = os.Mkdir(parent, 0777) 441 if err != nil { 442 t.Fatal(err) 443 } 444 445 // for directories that were the root of an Add, we don't report creation, cf. watcher_darwin.go 446 f.assertEvents() 447 448 f.events = nil 449 f.WriteFile(file, "hello") 450 451 f.assertEvents(file) 452 } 453 454 func TestWatchNonexistentFileInNonexistentDirectory(t *testing.T) { 455 f := newNotifyFixture(t) 456 457 root := f.JoinPath("root") 458 err := os.Mkdir(root, 0777) 459 if err != nil { 460 t.Fatal(err) 461 } 462 parent := f.JoinPath("parent") 463 file := f.JoinPath("parent", "a") 464 465 f.watch(file) 466 f.assertEvents() 467 468 err = os.Mkdir(parent, 0777) 469 if err != nil { 470 t.Fatal(err) 471 } 472 473 f.assertEvents() 474 f.WriteFile(file, "hello") 475 f.assertEvents(file) 476 } 477 478 func TestWatchCountInnerFile(t *testing.T) { 479 f := newNotifyFixture(t) 480 481 root := f.paths[0] 482 a := f.JoinPath(root, "a") 483 b := f.JoinPath(a, "b") 484 file := f.JoinPath(b, "bigFile") 485 f.WriteFile(file, "hello") 486 f.assertEvents(a, b, file) 487 488 expectedWatches := 3 489 if isRecursiveWatcher() { 490 expectedWatches = 1 491 } 492 assert.Equal(t, expectedWatches, int(numberOfWatches.Value())) 493 } 494 495 func TestWatchCountInnerFileWithIgnore(t *testing.T) { 496 f := newNotifyFixture(t) 497 498 root := f.paths[0] 499 ignore, _ := dockerignore.NewDockerPatternMatcher(root, []string{ 500 "a", 501 "!a/b", 502 }) 503 f.setIgnore(ignore) 504 505 a := f.JoinPath(root, "a") 506 b := f.JoinPath(a, "b") 507 file := f.JoinPath(b, "bigFile") 508 f.WriteFile(file, "hello") 509 f.assertEvents(b, file) 510 511 expectedWatches := 3 512 if isRecursiveWatcher() { 513 expectedWatches = 1 514 } 515 assert.Equal(t, expectedWatches, int(numberOfWatches.Value())) 516 } 517 518 func TestIgnoreCreatedDir(t *testing.T) { 519 f := newNotifyFixture(t) 520 521 root := f.paths[0] 522 ignore, _ := dockerignore.NewDockerPatternMatcher(root, []string{"a/b"}) 523 f.setIgnore(ignore) 524 525 a := f.JoinPath(root, "a") 526 b := f.JoinPath(a, "b") 527 file := f.JoinPath(b, "bigFile") 528 f.WriteFile(file, "hello") 529 f.assertEvents(a) 530 531 expectedWatches := 2 532 if isRecursiveWatcher() { 533 expectedWatches = 1 534 } 535 assert.Equal(t, expectedWatches, int(numberOfWatches.Value())) 536 } 537 538 func TestIgnoreCreatedDirWithExclusions(t *testing.T) { 539 f := newNotifyFixture(t) 540 541 root := f.paths[0] 542 ignore, _ := dockerignore.NewDockerPatternMatcher(root, 543 []string{ 544 "a/b", 545 "c", 546 "!c/d", 547 }) 548 f.setIgnore(ignore) 549 550 a := f.JoinPath(root, "a") 551 b := f.JoinPath(a, "b") 552 file := f.JoinPath(b, "bigFile") 553 f.WriteFile(file, "hello") 554 f.assertEvents(a) 555 556 expectedWatches := 2 557 if isRecursiveWatcher() { 558 expectedWatches = 1 559 } 560 assert.Equal(t, expectedWatches, int(numberOfWatches.Value())) 561 } 562 563 func TestIgnoreInitialDir(t *testing.T) { 564 f := newNotifyFixture(t) 565 566 root := f.TempDir("root") 567 ignore, _ := dockerignore.NewDockerPatternMatcher(root, []string{"a/b"}) 568 f.setIgnore(ignore) 569 570 a := f.JoinPath(root, "a") 571 b := f.JoinPath(a, "b") 572 file := f.JoinPath(b, "bigFile") 573 f.WriteFile(file, "hello") 574 f.watch(root) 575 576 f.assertEvents() 577 578 expectedWatches := 3 579 if isRecursiveWatcher() { 580 expectedWatches = 2 581 } 582 assert.Equal(t, expectedWatches, int(numberOfWatches.Value())) 583 } 584 585 func isRecursiveWatcher() bool { 586 return runtime.GOOS == "darwin" || runtime.GOOS == "windows" 587 } 588 589 type notifyFixture struct { 590 ctx context.Context 591 cancel func() 592 out *bytes.Buffer 593 *tempdir.TempDirFixture 594 notify Notify 595 ignore PathMatcher 596 paths []string 597 events []FileEvent 598 } 599 600 func newNotifyFixture(t *testing.T) *notifyFixture { 601 out := bytes.NewBuffer(nil) 602 ctx, cancel := context.WithCancel(context.Background()) 603 nf := ¬ifyFixture{ 604 ctx: ctx, 605 cancel: cancel, 606 TempDirFixture: tempdir.NewTempDirFixture(t), 607 paths: []string{}, 608 ignore: EmptyMatcher{}, 609 out: out, 610 } 611 nf.watch(nf.TempDir("watched")) 612 t.Cleanup(nf.tearDown) 613 return nf 614 } 615 616 func (f *notifyFixture) setIgnore(ignore PathMatcher) { 617 f.ignore = ignore 618 f.rebuildWatcher() 619 } 620 621 func (f *notifyFixture) watch(path string) { 622 f.paths = append(f.paths, path) 623 f.rebuildWatcher() 624 } 625 626 func (f *notifyFixture) rebuildWatcher() { 627 // sync any outstanding events and close the old watcher 628 if f.notify != nil { 629 f.fsync() 630 f.closeWatcher() 631 } 632 633 // create a new watcher 634 notify, err := NewWatcher(f.paths, f.ignore, logger.NewTestLogger(f.out)) 635 if err != nil { 636 f.T().Fatal(err) 637 } 638 f.notify = notify 639 err = f.notify.Start() 640 if err != nil { 641 f.T().Fatal(err) 642 } 643 } 644 645 func (f *notifyFixture) assertEvents(expected ...string) { 646 f.fsync() 647 if runtime.GOOS == "windows" { 648 // NOTE(nick): It's unclear to me why an extra fsync() helps 649 // here, but it makes the I/O way more predictable. 650 f.fsync() 651 } 652 653 if len(f.events) != len(expected) { 654 f.T().Fatalf("Got %d events (expected %d): %v %v", len(f.events), len(expected), f.events, expected) 655 } 656 657 for i, actual := range f.events { 658 e := FileEvent{expected[i]} 659 if actual != e { 660 f.T().Fatalf("Got event %v (expected %v)", actual, e) 661 } 662 } 663 } 664 665 func (f *notifyFixture) consumeEventsInBackground(ctx context.Context) chan error { 666 done := make(chan error) 667 go func() { 668 for { 669 select { 670 case <-f.ctx.Done(): 671 close(done) 672 return 673 case <-ctx.Done(): 674 close(done) 675 return 676 case err := <-f.notify.Errors(): 677 done <- err 678 close(done) 679 return 680 case <-f.notify.Events(): 681 } 682 } 683 }() 684 return done 685 } 686 687 func (f *notifyFixture) fsync() { 688 f.fsyncWithRetryCount(3) 689 } 690 691 func (f *notifyFixture) fsyncWithRetryCount(retryCount int) { 692 if len(f.paths) == 0 { 693 return 694 } 695 696 syncPathBase := fmt.Sprintf("sync-%d.txt", time.Now().UnixNano()) 697 syncPath := filepath.Join(f.paths[0], syncPathBase) 698 anySyncPath := filepath.Join(f.paths[0], "sync-") 699 timeout := time.After(250 * time.Millisecond) 700 701 f.WriteFile(syncPath, time.Now().String()) 702 703 F: 704 for { 705 select { 706 case <-f.ctx.Done(): 707 return 708 case err := <-f.notify.Errors(): 709 f.T().Fatal(err) 710 711 case event := <-f.notify.Events(): 712 if strings.Contains(event.Path(), syncPath) { 713 break F 714 } 715 if strings.Contains(event.Path(), anySyncPath) { 716 continue 717 } 718 719 // Don't bother tracking duplicate changes to the same path 720 // for testing. 721 if len(f.events) > 0 && f.events[len(f.events)-1].Path() == event.Path() { 722 continue 723 } 724 725 f.events = append(f.events, event) 726 727 case <-timeout: 728 if retryCount <= 0 { 729 f.T().Fatalf("fsync: timeout") 730 } else { 731 f.fsyncWithRetryCount(retryCount - 1) 732 } 733 return 734 } 735 } 736 } 737 738 func (f *notifyFixture) closeWatcher() { 739 notify := f.notify 740 err := notify.Close() 741 if err != nil { 742 f.T().Fatal(err) 743 } 744 745 // drain channels from watcher 746 go func() { 747 for range notify.Events() { 748 } 749 }() 750 751 go func() { 752 for range notify.Errors() { 753 } 754 }() 755 } 756 757 func (f *notifyFixture) tearDown() { 758 f.cancel() 759 f.closeWatcher() 760 numberOfWatches.Set(0) 761 }