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  }