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  }