github.com/connorvict/air@v0.0.0-20231005162537-279bf07db0d5/runner/engine.go (about) 1 package runner 2 3 import ( 4 "fmt" 5 "io" 6 "os" 7 "os/exec" 8 "path/filepath" 9 "strings" 10 "sync" 11 "sync/atomic" 12 "time" 13 14 "github.com/gohugoio/hugo/watcher/filenotify" 15 ) 16 17 // Engine ... 18 type Engine struct { 19 config *Config 20 logger *logger 21 watcher filenotify.FileWatcher 22 debugMode bool 23 runArgs []string 24 running bool 25 26 eventCh chan string 27 watcherStopCh chan bool 28 buildRunCh chan bool 29 buildRunStopCh chan bool 30 canExit chan bool 31 binStopCh chan bool 32 exitCh chan bool 33 34 mu sync.RWMutex 35 watchers uint 36 round uint64 37 fileChecksums *checksumMap 38 39 ll sync.Mutex // lock for logger 40 } 41 42 // NewEngineWithConfig ... 43 func NewEngineWithConfig(cfg *Config, debugMode bool) (*Engine, error) { 44 logger := newLogger(cfg) 45 watcher, err := newWatcher(cfg) 46 if err != nil { 47 return nil, err 48 } 49 e := Engine{ 50 config: cfg, 51 logger: logger, 52 watcher: watcher, 53 debugMode: debugMode, 54 runArgs: cfg.Build.ArgsBin, 55 eventCh: make(chan string, 1000), 56 watcherStopCh: make(chan bool, 10), 57 buildRunCh: make(chan bool, 1), 58 buildRunStopCh: make(chan bool, 1), 59 canExit: make(chan bool, 1), 60 binStopCh: make(chan bool), 61 exitCh: make(chan bool), 62 fileChecksums: &checksumMap{m: make(map[string]string)}, 63 watchers: 0, 64 } 65 66 return &e, nil 67 } 68 69 // NewEngine ... 70 func NewEngine(cfgPath string, debugMode bool) (*Engine, error) { 71 var err error 72 cfg, err := InitConfig(cfgPath) 73 if err != nil { 74 return nil, err 75 } 76 return NewEngineWithConfig(cfg, debugMode) 77 } 78 79 // Run run run 80 func (e *Engine) Run() { 81 if len(os.Args) > 1 && os.Args[1] == "init" { 82 writeDefaultConfig() 83 return 84 } 85 86 e.mainDebug("CWD: %s", e.config.Root) 87 88 var err error 89 if err = e.checkRunEnv(); err != nil { 90 os.Exit(1) 91 } 92 if err = e.watching(e.config.Root); err != nil { 93 os.Exit(1) 94 } 95 96 e.start() 97 e.cleanup() 98 } 99 100 func (e *Engine) checkRunEnv() error { 101 p := e.config.tmpPath() 102 if _, err := os.Stat(p); os.IsNotExist(err) { 103 e.runnerLog("mkdir %s", p) 104 if err := os.Mkdir(p, 0o755); err != nil { 105 e.runnerLog("failed to mkdir, error: %s", err.Error()) 106 return err 107 } 108 } 109 return nil 110 } 111 112 func (e *Engine) watching(root string) error { 113 return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 114 // NOTE: path is absolute 115 if info != nil && !info.IsDir() { 116 if e.checkIncludeFile(path) { 117 return e.watchPath(path) 118 } 119 return nil 120 } 121 // exclude tmp dir 122 if e.isTmpDir(path) { 123 e.watcherLog("!exclude %s", e.config.rel(path)) 124 return filepath.SkipDir 125 } 126 // exclude testdata dir 127 if e.isTestDataDir(path) { 128 e.watcherLog("!exclude %s", e.config.rel(path)) 129 return filepath.SkipDir 130 } 131 // exclude hidden directories like .git, .idea, etc. 132 if isHiddenDirectory(path) { 133 return filepath.SkipDir 134 } 135 // exclude user specified directories 136 if e.isExcludeDir(path) { 137 e.watcherLog("!exclude %s", e.config.rel(path)) 138 return filepath.SkipDir 139 } 140 isIn, walkDir := e.checkIncludeDir(path) 141 if !walkDir { 142 e.watcherLog("!exclude %s", e.config.rel(path)) 143 return filepath.SkipDir 144 } 145 if isIn { 146 return e.watchPath(path) 147 } 148 return nil 149 }) 150 } 151 152 // cacheFileChecksums calculates and stores checksums for each non-excluded file it finds from root. 153 func (e *Engine) cacheFileChecksums(root string) error { 154 return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { 155 if err != nil { 156 if info == nil { 157 return err 158 } 159 if info.IsDir() { 160 return filepath.SkipDir 161 } 162 return err 163 } 164 165 if !info.Mode().IsRegular() { 166 if e.isTmpDir(path) || e.isTestDataDir(path) || isHiddenDirectory(path) || e.isExcludeDir(path) { 167 e.watcherDebug("!exclude checksum %s", e.config.rel(path)) 168 return filepath.SkipDir 169 } 170 171 // Follow symbolic link 172 if e.config.Build.FollowSymlink && (info.Mode()&os.ModeSymlink) > 0 { 173 link, err := filepath.EvalSymlinks(path) 174 if err != nil { 175 return err 176 } 177 linkInfo, err := os.Stat(link) 178 if err != nil { 179 return err 180 } 181 if linkInfo.IsDir() { 182 err = e.watchPath(link) 183 if err != nil { 184 return err 185 } 186 } 187 return nil 188 } 189 } 190 191 if e.isExcludeFile(path) || !e.isIncludeExt(path) { 192 e.watcherDebug("!exclude checksum %s", e.config.rel(path)) 193 return nil 194 } 195 196 excludeRegex, err := e.isExcludeRegex(path) 197 if err != nil { 198 return err 199 } 200 if excludeRegex { 201 e.watcherDebug("!exclude checksum %s", e.config.rel(path)) 202 return nil 203 } 204 205 // update the checksum cache for the current file 206 _ = e.isModified(path) 207 208 return nil 209 }) 210 } 211 212 func (e *Engine) watchPath(path string) error { 213 if err := e.watcher.Add(path); err != nil { 214 e.watcherLog("failed to watch %s, error: %s", path, err.Error()) 215 return err 216 } 217 e.watcherLog("watching %s", e.config.rel(path)) 218 219 go func() { 220 e.withLock(func() { 221 e.watchers++ 222 }) 223 defer func() { 224 e.withLock(func() { 225 e.watchers-- 226 }) 227 }() 228 229 if e.config.Build.ExcludeUnchanged { 230 err := e.cacheFileChecksums(path) 231 if err != nil { 232 e.watcherLog("error building checksum cache: %v", err) 233 } 234 } 235 236 for { 237 select { 238 case <-e.watcherStopCh: 239 return 240 case ev := <-e.watcher.Events(): 241 e.mainDebug("event: %+v", ev) 242 if !validEvent(ev) { 243 break 244 } 245 if isDir(ev.Name) { 246 e.watchNewDir(ev.Name, removeEvent(ev)) 247 break 248 } 249 if e.isExcludeFile(ev.Name) { 250 break 251 } 252 excludeRegex, _ := e.isExcludeRegex(ev.Name) 253 if excludeRegex { 254 break 255 } 256 if !e.isIncludeExt(ev.Name) { 257 break 258 } 259 e.watcherDebug("%s has changed", e.config.rel(ev.Name)) 260 e.eventCh <- ev.Name 261 case err := <-e.watcher.Errors(): 262 e.watcherLog("error: %s", err.Error()) 263 } 264 } 265 }() 266 return nil 267 } 268 269 func (e *Engine) watchNewDir(dir string, removeDir bool) { 270 if e.isTmpDir(dir) { 271 return 272 } 273 if e.isTestDataDir(dir) { 274 return 275 } 276 if isHiddenDirectory(dir) || e.isExcludeDir(dir) { 277 e.watcherLog("!exclude %s", e.config.rel(dir)) 278 return 279 } 280 if removeDir { 281 if err := e.watcher.Remove(dir); err != nil { 282 e.watcherLog("failed to stop watching %s, error: %s", dir, err.Error()) 283 } 284 return 285 } 286 go func(dir string) { 287 if err := e.watching(dir); err != nil { 288 e.watcherLog("failed to watching %s, error: %s", dir, err.Error()) 289 } 290 }(dir) 291 } 292 293 func (e *Engine) isModified(filename string) bool { 294 newChecksum, err := fileChecksum(filename) 295 if err != nil { 296 e.watcherDebug("can't determine if file was changed: %v - assuming it did without updating cache", err) 297 return true 298 } 299 300 if e.fileChecksums.updateFileChecksum(filename, newChecksum) { 301 e.watcherDebug("stored checksum for %s: %s", e.config.rel(filename), newChecksum) 302 return true 303 } 304 305 return false 306 } 307 308 // Endless loop and never return 309 func (e *Engine) start() { 310 e.running = true 311 firstRunCh := make(chan bool, 1) 312 firstRunCh <- true 313 314 for { 315 var filename string 316 317 select { 318 case <-e.exitCh: 319 e.mainDebug("exit in start") 320 return 321 case filename = <-e.eventCh: 322 if !e.isIncludeExt(filename) { 323 continue 324 } 325 if e.config.Build.ExcludeUnchanged { 326 if !e.isModified(filename) { 327 e.mainLog("skipping %s because contents unchanged", e.config.rel(filename)) 328 continue 329 } 330 } 331 332 time.Sleep(e.config.buildDelay()) 333 e.flushEvents() 334 335 if e.config.Screen.ClearOnRebuild { 336 if e.config.Screen.KeepScroll { 337 // https://stackoverflow.com/questions/22891644/how-can-i-clear-the-terminal-screen-in-go 338 fmt.Print("\033[2J") 339 } else { 340 // https://stackoverflow.com/questions/5367068/clear-a-terminal-screen-for-real/5367075#5367075 341 fmt.Print("\033c") 342 } 343 } 344 345 e.mainLog("%s has changed", e.config.rel(filename)) 346 case <-firstRunCh: 347 // go down 348 } 349 350 // already build and run now 351 select { 352 case <-e.buildRunCh: 353 e.buildRunStopCh <- true 354 default: 355 } 356 357 // if current app is running, stop it 358 e.withLock(func() { 359 close(e.binStopCh) 360 e.binStopCh = make(chan bool) 361 }) 362 go e.buildRun() 363 } 364 } 365 366 func (e *Engine) buildRun() { 367 e.buildRunCh <- true 368 defer func() { 369 <-e.buildRunCh 370 }() 371 372 select { 373 case <-e.buildRunStopCh: 374 return 375 case <-e.canExit: 376 default: 377 } 378 var err error 379 if err = e.runPreCmd(); err != nil { 380 e.canExit <- true 381 e.runnerLog("failed to execute pre_cmd: %s", err.Error()) 382 if e.config.Build.StopOnError { 383 return 384 } 385 } 386 if err = e.building(); err != nil { 387 e.canExit <- true 388 e.buildLog("failed to build, error: %s", err.Error()) 389 _ = e.writeBuildErrorLog(err.Error()) 390 if e.config.Build.StopOnError { 391 return 392 } 393 } 394 395 select { 396 case <-e.buildRunStopCh: 397 return 398 case <-e.exitCh: 399 e.mainDebug("exit in buildRun") 400 close(e.canExit) 401 return 402 default: 403 } 404 if err = e.runBin(); err != nil { 405 e.runnerLog("failed to run, error: %s", err.Error()) 406 } 407 } 408 409 func (e *Engine) flushEvents() { 410 for { 411 select { 412 case <-e.eventCh: 413 e.mainDebug("flushing events") 414 default: 415 return 416 } 417 } 418 } 419 420 // utility to execute commands, such as cmd & pre_cmd 421 func (e *Engine) runCommand(command string) error { 422 cmd, stdout, stderr, err := e.startCmd(command) 423 if err != nil { 424 return err 425 } 426 defer func() { 427 stdout.Close() 428 stderr.Close() 429 }() 430 _, _ = io.Copy(os.Stdout, stdout) 431 _, _ = io.Copy(os.Stderr, stderr) 432 // wait for command to finish 433 err = cmd.Wait() 434 if err != nil { 435 return err 436 } 437 return nil 438 } 439 440 // run cmd option in .air.toml 441 func (e *Engine) building() error { 442 e.buildLog("building...") 443 err := e.runCommand(e.config.Build.Cmd) 444 if err != nil { 445 return err 446 } 447 return nil 448 } 449 450 // run pre_cmd option in .air.toml 451 func (e *Engine) runPreCmd() error { 452 for _, command := range e.config.Build.PreCmd { 453 e.runnerLog("> %s", command) 454 err := e.runCommand(command) 455 if err != nil { 456 return err 457 } 458 } 459 return nil 460 } 461 462 // run post_cmd option in .air.toml 463 func (e *Engine) runPostCmd() error { 464 for _, command := range e.config.Build.PostCmd { 465 e.runnerLog("> %s", command) 466 err := e.runCommand(command) 467 if err != nil { 468 return err 469 } 470 } 471 return nil 472 } 473 474 func (e *Engine) runBin() error { 475 // control killFunc should be kill or not 476 killCh := make(chan struct{}) 477 wg := sync.WaitGroup{} 478 go func() { 479 // listen to binStopCh 480 // cleanup() will close binStopCh when engine stop 481 // start() will close binStopCh when file changed 482 <-e.binStopCh 483 close(killCh) 484 485 select { 486 case <-e.exitCh: 487 wg.Wait() 488 close(e.canExit) 489 default: 490 } 491 }() 492 493 killFunc := func(cmd *exec.Cmd, stdout io.ReadCloser, stderr io.ReadCloser, killCh chan struct{}, processExit chan struct{}, wg *sync.WaitGroup) { 494 defer wg.Done() 495 select { 496 // the process haven't exited yet, kill it 497 case <-killCh: 498 break 499 500 // the process is exited, return 501 case <-processExit: 502 return 503 } 504 505 e.mainDebug("trying to kill pid %d, cmd %+v", cmd.Process.Pid, cmd.Args) 506 defer func() { 507 stdout.Close() 508 stderr.Close() 509 }() 510 pid, err := e.killCmd(cmd) 511 if err != nil { 512 e.mainDebug("failed to kill PID %d, error: %s", pid, err.Error()) 513 if cmd.ProcessState != nil && !cmd.ProcessState.Exited() { 514 os.Exit(1) 515 } 516 } else { 517 e.mainDebug("cmd killed, pid: %d", pid) 518 } 519 cmdBinPath := cmdPath(e.config.rel(e.config.binPath())) 520 if _, err = os.Stat(cmdBinPath); os.IsNotExist(err) { 521 return 522 } 523 if err = os.Remove(cmdBinPath); err != nil { 524 e.mainLog("failed to remove %s, error: %s", e.config.rel(e.config.binPath()), err) 525 } 526 } 527 528 e.runnerLog("running...") 529 go func() { 530 for { 531 select { 532 case <-killCh: 533 return 534 default: 535 command := strings.Join(append([]string{e.config.Build.Bin}, e.runArgs...), " ") 536 cmd, stdout, stderr, _ := e.startCmd(command) 537 processExit := make(chan struct{}) 538 e.mainDebug("running process pid %v", cmd.Process.Pid) 539 540 wg.Add(1) 541 atomic.AddUint64(&e.round, 1) 542 go killFunc(cmd, stdout, stderr, killCh, processExit, &wg) 543 544 _, _ = io.Copy(os.Stdout, stdout) 545 _, _ = io.Copy(os.Stderr, stderr) 546 _, _ = cmd.Process.Wait() 547 close(processExit) 548 549 if !e.config.Build.Rerun { 550 return 551 } 552 time.Sleep(e.config.rerunDelay()) 553 } 554 } 555 }() 556 var ReloadChannel = make(chan bool) 557 ReloadChannel <- true 558 559 return nil 560 } 561 562 func (e *Engine) cleanup() { 563 e.mainLog("cleaning...") 564 defer e.mainLog("see you again~") 565 566 e.withLock(func() { 567 close(e.binStopCh) 568 e.binStopCh = make(chan bool) 569 }) 570 e.mainDebug("wating for close watchers..") 571 572 e.withLock(func() { 573 for i := 0; i < int(e.watchers); i++ { 574 e.watcherStopCh <- true 575 } 576 }) 577 578 e.mainDebug("waiting for buildRun...") 579 var err error 580 if err = e.watcher.Close(); err != nil { 581 e.mainLog("failed to close watcher, error: %s", err.Error()) 582 } 583 584 e.mainDebug("waiting for clean ...") 585 586 if e.config.Misc.CleanOnExit { 587 e.mainLog("deleting %s", e.config.tmpPath()) 588 if err = os.RemoveAll(e.config.tmpPath()); err != nil { 589 e.mainLog("failed to delete tmp dir, err: %+v", err) 590 } 591 } 592 593 e.mainDebug("waiting for exit...") 594 595 <-e.canExit 596 e.running = false 597 e.mainDebug("exited") 598 } 599 600 // Stop the air 601 func (e *Engine) Stop() { 602 if err := e.runPostCmd(); err != nil { 603 e.runnerLog("failed to execute post_cmd, error: %s", err.Error()) 604 } 605 close(e.exitCh) 606 }