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 }