tractor.dev/toolkit-go@v0.0.0-20241010005851-214d91207d07/engine/fs/watchfs/watcher/watcher.go (about) 1 package watcher 2 3 import ( 4 "errors" 5 "fmt" 6 "io/fs" 7 "os" 8 "path/filepath" 9 "regexp" 10 "strings" 11 "sync" 12 "time" 13 ) 14 15 var ( 16 // ErrDurationTooShort occurs when calling the watcher's Start 17 // method with a duration that's less than 1 nanosecond. 18 ErrDurationTooShort = errors.New("error: duration is less than 1ns") 19 20 // ErrWatcherRunning occurs when trying to call the watcher's 21 // Start method and the polling cycle is still already running 22 // from previously calling Start and not yet calling Close. 23 ErrWatcherRunning = errors.New("error: watcher is already running") 24 25 // ErrWatchedFileDeleted is an error that occurs when a file or folder that was 26 // being watched has been deleted. 27 ErrWatchedFileDeleted = errors.New("error: watched file or folder deleted") 28 29 // ErrSkip is less of an error, but more of a way for path hooks to skip a file or 30 // directory. 31 ErrSkip = errors.New("error: skipping file") 32 ) 33 34 // An Op is a type that is used to describe what type 35 // of event has occurred during the watching process. 36 type Op uint32 37 38 // Ops 39 const ( 40 Create Op = iota 41 Write 42 Remove 43 Rename 44 Chmod 45 Move 46 ) 47 48 var ops = map[Op]string{ 49 Create: "CREATE", 50 Write: "WRITE", 51 Remove: "REMOVE", 52 Rename: "RENAME", 53 Chmod: "CHMOD", 54 Move: "MOVE", 55 } 56 57 // String prints the string version of the Op consts 58 func (e Op) String() string { 59 if op, found := ops[e]; found { 60 return op 61 } 62 return "???" 63 } 64 65 // An Event describes an event that is received when files or directory 66 // changes occur. It includes the os.FileInfo of the changed file or 67 // directory and the type of event that's occurred and the full path of the file. 68 type Event struct { 69 Op 70 Path string 71 OldPath string 72 fs.FileInfo 73 } 74 75 // String returns a string depending on what type of event occurred and the 76 // file name associated with the event. 77 func (e Event) String() string { 78 if e.FileInfo == nil { 79 return "???" 80 } 81 82 pathType := "FILE" 83 if e.IsDir() { 84 pathType = "DIRECTORY" 85 } 86 return fmt.Sprintf("%s %q %s [%s]", pathType, e.Name(), e.Op, e.Path) 87 } 88 89 // FilterFileHookFunc is a function that is called to filter files during listings. 90 // If a file is ok to be listed, nil is returned otherwise ErrSkip is returned. 91 type FilterFileHookFunc func(info fs.FileInfo, fullPath string) error 92 93 // RegexFilterHook is a function that accepts or rejects a file 94 // for listing based on whether it's filename or full path matches 95 // a regular expression. 96 func RegexFilterHook(r *regexp.Regexp, useFullPath bool) FilterFileHookFunc { 97 return func(info fs.FileInfo, fullPath string) error { 98 str := info.Name() 99 100 if useFullPath { 101 str = fullPath 102 } 103 104 // Match 105 if r.MatchString(str) { 106 return nil 107 } 108 109 // No match. 110 return ErrSkip 111 } 112 } 113 114 // Watcher describes a process that watches files for changes. 115 type Watcher struct { 116 Event chan Event 117 Error chan error 118 Closed chan struct{} 119 close chan struct{} 120 wg *sync.WaitGroup 121 122 // mu protects the following. 123 mu *sync.Mutex 124 ffh []FilterFileHookFunc 125 running bool 126 names map[string]bool // bool for recursive or not. 127 files map[string]fs.FileInfo // map of files. 128 ignored map[string]struct{} // ignored files or directories. 129 ops map[Op]struct{} // Op filtering. 130 ignoreHidden bool // ignore hidden files or not. 131 maxEvents int // max sent events per cycle 132 fs fs.StatFS // filesystem to use 133 } 134 135 // New creates a new Watcher. 136 func New(fsys fs.FS) *Watcher { 137 sfs := fsys.(fs.StatFS) // for now, let panic if not StatFS 138 139 // Set up the WaitGroup for w.Wait(). 140 var wg sync.WaitGroup 141 wg.Add(1) 142 143 return &Watcher{ 144 Event: make(chan Event), 145 Error: make(chan error), 146 Closed: make(chan struct{}), 147 close: make(chan struct{}), 148 mu: new(sync.Mutex), 149 wg: &wg, 150 files: make(map[string]fs.FileInfo), 151 ignored: make(map[string]struct{}), 152 names: make(map[string]bool), 153 fs: sfs, 154 } 155 } 156 157 // SetMaxEvents controls the maximum amount of events that are sent on 158 // the Event channel per watching cycle. If max events is less than 1, there is 159 // no limit, which is the default. 160 func (w *Watcher) SetMaxEvents(delta int) { 161 w.mu.Lock() 162 w.maxEvents = delta 163 w.mu.Unlock() 164 } 165 166 // AddFilterHook 167 func (w *Watcher) AddFilterHook(f FilterFileHookFunc) { 168 w.mu.Lock() 169 w.ffh = append(w.ffh, f) 170 w.mu.Unlock() 171 } 172 173 // IgnoreHiddenFiles sets the watcher to ignore any file or directory 174 // that starts with a dot. 175 func (w *Watcher) IgnoreHiddenFiles(ignore bool) { 176 w.mu.Lock() 177 w.ignoreHidden = ignore 178 w.mu.Unlock() 179 } 180 181 // FilterOps filters which event op types should be returned 182 // when an event occurs. 183 func (w *Watcher) FilterOps(ops ...Op) { 184 w.mu.Lock() 185 w.ops = make(map[Op]struct{}) 186 for _, op := range ops { 187 w.ops[op] = struct{}{} 188 } 189 w.mu.Unlock() 190 } 191 192 // Add adds either a single file or directory to the file list. 193 func (w *Watcher) Add(name string) (err error) { 194 w.mu.Lock() 195 defer w.mu.Unlock() 196 197 name = filepath.Clean(name) 198 if name[0] == '/' { 199 name = name[1:] 200 } 201 202 // If name is on the ignored list or if hidden files are 203 // ignored and name is a hidden file or directory, simply return. 204 _, ignored := w.ignored[name] 205 206 isHidden, err := isHiddenFile(name) 207 if err != nil { 208 return err 209 } 210 211 if ignored || (w.ignoreHidden && isHidden) { 212 return nil 213 } 214 215 // Add the directory's contents to the files list. 216 fileList, err := w.list(name) 217 if err != nil { 218 return err 219 } 220 for k, v := range fileList { 221 w.files[k] = v 222 } 223 224 // Add the name to the names list. 225 w.names[name] = false 226 227 return nil 228 } 229 230 func (w *Watcher) list(name string) (map[string]fs.FileInfo, error) { 231 fileList := make(map[string]fs.FileInfo) 232 233 // Make sure name exists. 234 stat, err := w.fs.Stat(name) 235 if err != nil { 236 return nil, err 237 } 238 239 // make a copy of the fileinfo 240 // in case the fs is passing it by 241 // reference and the values will just 242 // update. we need values at this time. 243 fileList[name] = &fileInfo{ 244 name: stat.Name(), 245 mode: stat.Mode(), 246 size: stat.Size(), 247 sys: stat.Sys(), 248 modTime: stat.ModTime(), 249 dir: stat.IsDir(), 250 } 251 252 // If it's not a directory, just return. 253 if !stat.IsDir() { 254 return fileList, nil 255 } 256 257 // It's a directory. 258 entries, err := fs.ReadDir(w.fs, name) 259 if err != nil { 260 return nil, err 261 } 262 // Add all of the files in the directory to the file list as long 263 // as they aren't on the ignored list or are hidden files if ignoreHidden 264 // is set to true. 265 outer: 266 for _, entry := range entries { 267 fInfo, err := entry.Info() 268 if err != nil { 269 return nil, err 270 } 271 path := filepath.Join(name, fInfo.Name()) 272 _, ignored := w.ignored[path] 273 274 isHidden, err := isHiddenFile(path) 275 if err != nil { 276 return nil, err 277 } 278 279 if ignored || (w.ignoreHidden && isHidden) { 280 continue 281 } 282 283 for _, f := range w.ffh { 284 err := f(fInfo, path) 285 if err == ErrSkip { 286 continue outer 287 } 288 if err != nil { 289 return nil, err 290 } 291 } 292 293 fileList[path] = &fileInfo{ 294 name: fInfo.Name(), 295 mode: fInfo.Mode(), 296 size: fInfo.Size(), 297 sys: fInfo.Sys(), 298 modTime: fInfo.ModTime(), 299 dir: fInfo.IsDir(), 300 } 301 } 302 return fileList, nil 303 } 304 305 // AddRecursive adds either a single file or directory recursively to the file list. 306 func (w *Watcher) AddRecursive(name string) (err error) { 307 w.mu.Lock() 308 defer w.mu.Unlock() 309 310 name = filepath.Clean(name) 311 if name[0] == '/' { 312 name = name[1:] 313 } 314 315 fileList, err := w.listRecursive(name) 316 if err != nil { 317 return err 318 } 319 for k, v := range fileList { 320 w.files[k] = v 321 } 322 323 // Add the name to the names list. 324 w.names[name] = true 325 326 return nil 327 } 328 329 func (w *Watcher) listRecursive(name string) (map[string]fs.FileInfo, error) { 330 fileList := make(map[string]fs.FileInfo) 331 332 return fileList, fs.WalkDir(w.fs, name, func(path string, d fs.DirEntry, err error) error { 333 if err != nil { 334 return err 335 } 336 337 // If path is ignored and it's a directory, skip the directory. If it's 338 // ignored and it's a single file, skip the file. 339 _, ignored := w.ignored[path] 340 341 isHidden, err := isHiddenFile(path) 342 if err != nil { 343 return err 344 } 345 346 if ignored || (w.ignoreHidden && isHidden) { 347 if d.IsDir() { 348 return filepath.SkipDir 349 } 350 return nil 351 } 352 353 info, err := d.Info() 354 if err != nil { 355 return err 356 } 357 358 for _, f := range w.ffh { 359 err := f(info, path) 360 if err == ErrSkip { 361 return nil 362 } 363 if err != nil { 364 return err 365 } 366 } 367 368 // Add the path and it's info to the file list. 369 fileList[path] = &fileInfo{ 370 name: info.Name(), 371 mode: info.Mode(), 372 size: info.Size(), 373 sys: info.Sys(), 374 modTime: info.ModTime(), 375 dir: info.IsDir(), 376 } 377 return nil 378 }) 379 } 380 381 // Remove removes either a single file or directory from the file's list. 382 func (w *Watcher) Remove(name string) (err error) { 383 w.mu.Lock() 384 defer w.mu.Unlock() 385 386 name = filepath.Clean(name) 387 if name[0] == '/' { 388 name = name[1:] 389 } 390 391 // Remove the name from w's names list. 392 delete(w.names, name) 393 394 // If name is a single file, remove it and return. 395 info, found := w.files[name] 396 if !found { 397 return nil // Doesn't exist, just return. 398 } 399 if !info.IsDir() { 400 delete(w.files, name) 401 return nil 402 } 403 404 // Delete the actual directory from w.files 405 delete(w.files, name) 406 407 // If it's a directory, delete all of it's contents from w.files. 408 for path := range w.files { 409 if filepath.Dir(path) == name { 410 delete(w.files, path) 411 } 412 } 413 return nil 414 } 415 416 // RemoveRecursive removes either a single file or a directory recursively from 417 // the file's list. 418 func (w *Watcher) RemoveRecursive(name string) (err error) { 419 w.mu.Lock() 420 defer w.mu.Unlock() 421 422 name = filepath.Clean(name) 423 if name[0] == '/' { 424 name = name[1:] 425 } 426 427 // Remove the name from w's names list. 428 delete(w.names, name) 429 430 // If name is a single file, remove it and return. 431 info, found := w.files[name] 432 if !found { 433 return nil // Doesn't exist, just return. 434 } 435 if !info.IsDir() { 436 delete(w.files, name) 437 return nil 438 } 439 440 // If it's a directory, delete all of it's contents recursively 441 // from w.files. 442 for path := range w.files { 443 if strings.HasPrefix(path, name) { 444 delete(w.files, path) 445 } 446 } 447 return nil 448 } 449 450 // Ignore adds paths that should be ignored. 451 // 452 // For files that are already added, Ignore removes them. 453 func (w *Watcher) Ignore(paths ...string) (err error) { 454 for _, path := range paths { 455 path = filepath.Clean(path) 456 // Remove any of the paths that were already added. 457 if err := w.RemoveRecursive(path); err != nil { 458 return err 459 } 460 w.mu.Lock() 461 w.ignored[path] = struct{}{} 462 w.mu.Unlock() 463 } 464 return nil 465 } 466 467 // WatchedFiles returns a map of files added to a Watcher. 468 func (w *Watcher) WatchedFiles() map[string]fs.FileInfo { 469 w.mu.Lock() 470 defer w.mu.Unlock() 471 472 files := make(map[string]fs.FileInfo) 473 for k, v := range w.files { 474 files[k] = v 475 } 476 477 return files 478 } 479 480 // fileInfo is an implementation of os.FileInfo that can be used 481 // as a mocked os.FileInfo when triggering an event when the specified 482 // os.FileInfo is nil. 483 type fileInfo struct { 484 name string 485 size int64 486 mode fs.FileMode 487 modTime time.Time 488 sys interface{} 489 dir bool 490 } 491 492 func (fs *fileInfo) IsDir() bool { 493 return fs.dir 494 } 495 func (fs *fileInfo) ModTime() time.Time { 496 return fs.modTime 497 } 498 func (fs *fileInfo) Mode() fs.FileMode { 499 return fs.mode 500 } 501 func (fs *fileInfo) Name() string { 502 return fs.name 503 } 504 func (fs *fileInfo) Size() int64 { 505 return fs.size 506 } 507 func (fs *fileInfo) Sys() interface{} { 508 return fs.sys 509 } 510 511 // TriggerEvent is a method that can be used to trigger an event, separate to 512 // the file watching process. 513 func (w *Watcher) TriggerEvent(eventType Op, file fs.FileInfo) { 514 w.Wait() 515 if file == nil { 516 file = &fileInfo{name: "triggered event", modTime: time.Now()} 517 } 518 w.Event <- Event{Op: eventType, Path: "-", FileInfo: file} 519 } 520 521 func (w *Watcher) retrieveFileList() map[string]fs.FileInfo { 522 w.mu.Lock() 523 defer w.mu.Unlock() 524 525 fileList := make(map[string]fs.FileInfo) 526 527 var list map[string]fs.FileInfo 528 var err error 529 530 for name, recursive := range w.names { 531 if recursive { 532 list, err = w.listRecursive(name) 533 if err != nil { 534 if os.IsNotExist(err) { 535 w.mu.Unlock() 536 if name == err.(*os.PathError).Path { 537 w.Error <- ErrWatchedFileDeleted 538 w.RemoveRecursive(name) 539 } 540 w.mu.Lock() 541 } else { 542 w.Error <- err 543 } 544 } 545 } else { 546 list, err = w.list(name) 547 if err != nil { 548 if os.IsNotExist(err) { 549 w.mu.Unlock() 550 // if name is a file being watch from another 551 // watched name, we dont want to remove it. 552 // TODO: figure out how to do this correctly. 553 554 // if name == err.(*os.PathError).Path { 555 // w.Error <- ErrWatchedFileDeleted 556 // w.Remove(name) 557 // } 558 w.mu.Lock() 559 } else { 560 w.Error <- err 561 } 562 } 563 } 564 // Add the file's to the file list. 565 for k, v := range list { 566 fileList[k] = v 567 } 568 } 569 570 return fileList 571 } 572 573 // Start begins the polling cycle which repeats every specified 574 // duration until Close is called. 575 func (w *Watcher) Start(d time.Duration) error { 576 // Return an error if d is less than 1 nanosecond. 577 if d < time.Nanosecond { 578 return ErrDurationTooShort 579 } 580 581 // Make sure the Watcher is not already running. 582 w.mu.Lock() 583 if w.running { 584 w.mu.Unlock() 585 return ErrWatcherRunning 586 } 587 w.running = true 588 w.mu.Unlock() 589 590 // Unblock w.Wait(). 591 w.wg.Done() 592 593 for { 594 // done lets the inner polling cycle loop know when the 595 // current cycle's method has finished executing. 596 done := make(chan struct{}) 597 598 // Any events that are found are first piped to evt before 599 // being sent to the main Event channel. 600 evt := make(chan Event) 601 602 // Retrieve the file list for all watched file's and dirs. 603 fileList := w.retrieveFileList() 604 605 // cancel can be used to cancel the current event polling function. 606 cancel := make(chan struct{}) 607 608 // Look for events. 609 go func() { 610 w.pollEvents(fileList, evt, cancel) 611 done <- struct{}{} 612 }() 613 614 // numEvents holds the number of events for the current cycle. 615 numEvents := 0 616 617 inner: 618 for { 619 select { 620 case <-w.close: 621 close(cancel) 622 close(w.Closed) 623 return nil 624 case event := <-evt: 625 if len(w.ops) > 0 { // Filter Ops. 626 _, found := w.ops[event.Op] 627 if !found { 628 continue 629 } 630 } 631 numEvents++ 632 if w.maxEvents > 0 && numEvents > w.maxEvents { 633 close(cancel) 634 break inner 635 } 636 w.Event <- event 637 case <-done: // Current cycle is finished. 638 break inner 639 } 640 } 641 642 // Update the file's list. 643 w.mu.Lock() 644 w.files = fileList 645 w.mu.Unlock() 646 647 // Sleep and then continue to the next loop iteration. 648 time.Sleep(d) 649 } 650 } 651 652 func (w *Watcher) pollEvents(files map[string]fs.FileInfo, evt chan Event, 653 cancel chan struct{}) { 654 w.mu.Lock() 655 defer w.mu.Unlock() 656 657 // Store create and remove events for use to check for rename events. 658 creates := make(map[string]fs.FileInfo) 659 removes := make(map[string]fs.FileInfo) 660 661 // Check for removed files. 662 for path, info := range w.files { 663 if _, found := files[path]; !found { 664 removes[path] = info 665 } 666 } 667 668 // Check for created files, writes and chmods. 669 for path, info := range files { 670 oldInfo, found := w.files[path] 671 if !found { 672 // A file was created. 673 creates[path] = info 674 continue 675 } 676 if oldInfo.ModTime() != info.ModTime() { 677 select { 678 case <-cancel: 679 return 680 case evt <- Event{Write, path, path, info}: 681 } 682 } 683 if oldInfo.Mode() != info.Mode() { 684 select { 685 case <-cancel: 686 return 687 case evt <- Event{Chmod, path, path, info}: 688 } 689 } 690 } 691 692 // Check for renames and moves. 693 for path1, info1 := range removes { 694 for path2, info2 := range creates { 695 if sameFile(info1, info2) { 696 e := Event{ 697 Op: Move, 698 Path: path2, 699 OldPath: path1, 700 FileInfo: info1, 701 } 702 // If they are from the same directory, it's a rename 703 // instead of a move event. 704 if filepath.Dir(path1) == filepath.Dir(path2) { 705 e.Op = Rename 706 } 707 708 newNames := map[string]bool{} 709 for path, recursive := range w.names { 710 if strings.HasPrefix(path+"/", path1+"/") { 711 newNames[strings.Replace(path, path1, path2, 1)] = recursive 712 } else { 713 newNames[path] = recursive 714 } 715 } 716 w.names = newNames 717 718 delete(removes, path1) 719 delete(creates, path2) 720 721 select { 722 case <-cancel: 723 return 724 case evt <- e: 725 } 726 } 727 } 728 } 729 730 // Send all the remaining create and remove events. 731 for path, info := range creates { 732 select { 733 case <-cancel: 734 return 735 case evt <- Event{Create, path, "", info}: 736 } 737 } 738 for path, info := range removes { 739 select { 740 case <-cancel: 741 return 742 case evt <- Event{Remove, path, path, info}: 743 } 744 } 745 } 746 747 // Wait blocks until the watcher is started. 748 func (w *Watcher) Wait() { 749 w.wg.Wait() 750 } 751 752 func (w *Watcher) IsRunning() bool { 753 return w.running 754 } 755 756 // Close stops a Watcher and unlocks its mutex, then sends a close signal. 757 func (w *Watcher) Close() { 758 w.mu.Lock() 759 if !w.running { 760 w.mu.Unlock() 761 return 762 } 763 w.running = false 764 w.files = make(map[string]fs.FileInfo) 765 w.names = make(map[string]bool) 766 w.mu.Unlock() 767 // Send a close signal to the Start method. 768 w.close <- struct{}{} 769 }