github.com/brandur/modulir@v0.0.0-20240305213423-94ee82929cbd/watch.go (about)

     1  package modulir
     2  
     3  import (
     4  	"path/filepath"
     5  	"strings"
     6  	"time"
     7  
     8  	"github.com/fsnotify/fsnotify"
     9  )
    10  
    11  //////////////////////////////////////////////////////////////////////////////
    12  //
    13  //
    14  //
    15  // Public
    16  //
    17  //
    18  //
    19  //////////////////////////////////////////////////////////////////////////////
    20  
    21  // Listens for file system changes from fsnotify and pushes relevant ones back
    22  // out over the rebuild channel.
    23  //
    24  // It doesn't start listening to fsnotify again until the main loop has
    25  // signaled rebuildDone, so there is a possibility that in the case of very
    26  // fast consecutive changes the build might not be perfectly up to date.
    27  func watchChanges(c *Context, watchEvents chan fsnotify.Event, watchErrors chan error,
    28  	rebuild chan map[string]struct{}, rebuildDone chan struct{},
    29  ) {
    30  	var changedSources, lastChangedSources map[string]struct{}
    31  	var lastRebuild time.Time
    32  
    33  	for {
    34  		select {
    35  		case event, ok := <-watchEvents:
    36  			if !ok {
    37  				c.Log.Infof("Watcher detected closed channel; stopping")
    38  				return
    39  			}
    40  
    41  			c.Log.Debugf("Received event from watcher: %+v", event)
    42  			lastChangedSources = changedSources
    43  			changedSources = map[string]struct{}{event.Name: {}}
    44  
    45  			if !shouldRebuild(event.Name, event.Op) {
    46  				continue
    47  			}
    48  
    49  			// The central purpose of this loop is to make sure we do as few
    50  			// build loops given incoming changes as possible.
    51  			//
    52  			// On the first receipt of a rebuild-eligible event we start
    53  			// rebuilding immediately, and during the rebuild we accumulate any
    54  			// other rebuild-eligible changes that stream in. When the initial
    55  			// build finishes, we loop and start a new one if there were
    56  			// changes since. If not, we return to the outer loop and continue
    57  			// watching for fsnotify events.
    58  			//
    59  			// If changes did come in, the inner for loop continues to work --
    60  			// triggering builds and accumulating changes while they're running
    61  			// -- until we're able to successfully execute a build loop without
    62  			// seeing a new change.
    63  			//
    64  			// The overwhelmingly common case will be few files being changed,
    65  			// and therefore the inner for almost never needs to loop.
    66  			for {
    67  				if len(changedSources) < 1 {
    68  					break
    69  				}
    70  
    71  				// If the detect changes are identical to the last set of
    72  				// changes we just processed and we're within a certain quiesce
    73  				// time, *don't* trigger another rebuild and just go back to
    74  				// steady state.
    75  				//
    76  				// This is to protect against a problem where for a single save
    77  				// operation, the watcher occasionally picks up a number of
    78  				// events on the same file in quick succession, but not *so*
    79  				// quick that the build can't finish before the next one comes
    80  				// in. The faster the build, the more often this is a problem.
    81  				//
    82  				// I'm not sure why this occurs, but protect against it.
    83  				if buildWithinSameFileQuiesce(lastRebuild, time.Now(), changedSources, lastChangedSources) {
    84  					c.Log.Infof("Identical file(s) %v changed within quiesce time; not rebuilding",
    85  						mapKeys(changedSources))
    86  					break
    87  				}
    88  
    89  				lastRebuild = time.Now()
    90  
    91  				// Start rebuild
    92  				rebuild <- changedSources
    93  
    94  				// Zero out the set of changes and start accumulating.
    95  				//
    96  				// Keep a pointer to it so that we can compare it to any new
    97  				// set of changes.
    98  				lastChangedSources = changedSources
    99  				changedSources = nil
   100  
   101  				// Wait until rebuild is finished. In the meantime, accumulate
   102  				// new events that come in on the watcher's channel and prepare
   103  				// for the next loop.
   104  			innerLoop:
   105  				for {
   106  					select {
   107  					case <-rebuildDone:
   108  						// Break and start next outer loop
   109  						break innerLoop
   110  
   111  					case event, ok := <-watchEvents:
   112  						if !ok {
   113  							c.Log.Infof("Watcher detected closed channel; stopping")
   114  							return
   115  						}
   116  
   117  						if !shouldRebuild(event.Name, event.Op) {
   118  							continue
   119  						}
   120  
   121  						if changedSources == nil {
   122  							changedSources = make(map[string]struct{})
   123  						}
   124  
   125  						changedSources[event.Name] = struct{}{}
   126  
   127  					case err, ok := <-watchErrors:
   128  						if !ok {
   129  							c.Log.Infof("Watcher detected closed channel; stopping")
   130  							return
   131  						}
   132  						c.Log.Errorf("Error from watcher:", err)
   133  					}
   134  				}
   135  			}
   136  
   137  		case err, ok := <-watchErrors:
   138  			if !ok {
   139  				c.Log.Infof("Watcher detected closed channel; stopping")
   140  				return
   141  			}
   142  			c.Log.Errorf("Error from watcher:", err)
   143  		}
   144  	}
   145  }
   146  
   147  //////////////////////////////////////////////////////////////////////////////
   148  //
   149  //
   150  //
   151  // Private
   152  //
   153  //
   154  //
   155  //////////////////////////////////////////////////////////////////////////////
   156  
   157  // The time window in which *not* to trigger a rebuild if the next set of
   158  // detected changes are on exactly the same files as the last.
   159  const sameFileQuiesceTime = 100 * time.Millisecond
   160  
   161  // See comment over this function's invocation.
   162  func buildWithinSameFileQuiesce(lastRebuild, now time.Time,
   163  	changedSources, lastChangedSources map[string]struct{},
   164  ) bool {
   165  	if lastChangedSources == nil {
   166  		return false
   167  	}
   168  
   169  	if now.Add(-sameFileQuiesceTime).After(lastRebuild) {
   170  		return false
   171  	}
   172  
   173  	return compareKeys(lastChangedSources, changedSources)
   174  }
   175  
   176  // Quick key comparison function for fun, but could use `reflect.DeepEqual`
   177  // alternatively. I didn't even benchmark so it's possible there's no
   178  // difference.
   179  func compareKeys(m1, m2 map[string]struct{}) bool {
   180  	if len(m1) != len(m2) {
   181  		return false
   182  	}
   183  
   184  	for k := range m1 {
   185  		_, ok := m2[k]
   186  		if !ok {
   187  			return false
   188  		}
   189  	}
   190  
   191  	for k := range m2 {
   192  		_, ok := m1[k]
   193  		if !ok {
   194  			return false
   195  		}
   196  	}
   197  
   198  	return true
   199  }
   200  
   201  // Decides whether a rebuild should be triggered given some input event
   202  // properties from fsnotify.
   203  func shouldRebuild(path string, op fsnotify.Op) bool {
   204  	base := filepath.Base(path)
   205  
   206  	// Mac OS' worst mistake.
   207  	if base == ".DS_Store" {
   208  		return false
   209  	}
   210  
   211  	// Vim creates this temporary file to see whether it can write into a
   212  	// target directory. It screws up our watching algorithm, so ignore it.
   213  	if base == "4913" {
   214  		return false
   215  	}
   216  
   217  	// A special case, but ignore creates on files that look like Vim backups.
   218  	if strings.HasSuffix(base, "~") {
   219  		return false
   220  	}
   221  
   222  	if op&fsnotify.Create != 0 {
   223  		return true
   224  	}
   225  
   226  	if op&fsnotify.Remove != 0 {
   227  		return true
   228  	}
   229  
   230  	if op&fsnotify.Write != 0 {
   231  		return true
   232  	}
   233  
   234  	// Ignore everything else. Rationale:
   235  	//
   236  	//   * chmod: We don't really care about these as they won't affect build
   237  	//     output. (Unless potentially we no longer can read the file, but
   238  	//     we'll go down that path if it ever becomes a problem.)
   239  	//
   240  	//   * rename: Will produce a following create event as well, so just
   241  	//     listen for that instead.
   242  	//
   243  	return false
   244  }