github.com/symfony-cli/symfony-cli@v0.0.0-20240514161054-ece2df437dfa/local/runner.go (about)

     1  /*
     2   * Copyright (c) 2021-present Fabien Potencier <fabien@symfony.com>
     3   *
     4   * This file is part of Symfony CLI project
     5   *
     6   * This program is free software: you can redistribute it and/or modify
     7   * it under the terms of the GNU Affero General Public License as
     8   * published by the Free Software Foundation, either version 3 of the
     9   * License, or (at your option) any later version.
    10   *
    11   * This program is distributed in the hope that it will be useful,
    12   * but WITHOUT ANY WARRANTY; without even the implied warranty of
    13   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    14   * GNU Affero General Public License for more details.
    15   *
    16   * You should have received a copy of the GNU Affero General Public License
    17   * along with this program. If not, see <http://www.gnu.org/licenses/>.
    18   */
    19  
    20  package local
    21  
    22  import (
    23  	"fmt"
    24  	"os"
    25  	"os/exec"
    26  	"os/signal"
    27  	"path/filepath"
    28  	"runtime"
    29  	"strconv"
    30  	"strings"
    31  	"syscall"
    32  	"time"
    33  
    34  	"github.com/pkg/errors"
    35  	"github.com/rs/zerolog"
    36  	"github.com/symfony-cli/console"
    37  	"github.com/symfony-cli/symfony-cli/inotify"
    38  	"github.com/symfony-cli/symfony-cli/local/pid"
    39  	"github.com/symfony-cli/symfony-cli/reexec"
    40  	"github.com/symfony-cli/symfony-cli/util"
    41  	"github.com/symfony-cli/terminal"
    42  )
    43  
    44  type runnerMode int
    45  
    46  const (
    47  	RunnerModeOnce         runnerMode = iota // run once
    48  	RunnerModeLoopAttached                   // run in the foreground and restart automatically in case of an error except if the first run failed
    49  	RunnerModeLoopDetached                   // run as a daemon (run in the background, restarted automatically in case of an error except if the first run failed)
    50  )
    51  
    52  const RunnerReliefDuration = 2 * time.Second
    53  
    54  type RunnerWentToBackground struct{}
    55  
    56  func (RunnerWentToBackground) Error() string { return "" }
    57  
    58  type Runner struct {
    59  	binary  string
    60  	mode    runnerMode
    61  	pidFile *pid.PidFile
    62  
    63  	BuildCmdHook        func(*exec.Cmd) error
    64  	SuccessHook         func(*Runner, *exec.Cmd)
    65  	AlwaysRestartOnExit bool
    66  }
    67  
    68  func NewRunner(pidFile *pid.PidFile, mode runnerMode) (*Runner, error) {
    69  	var err error
    70  	r := &Runner{
    71  		mode:    mode,
    72  		pidFile: pidFile,
    73  	}
    74  	r.binary, err = exec.LookPath(pidFile.Binary())
    75  	if err != nil {
    76  		r.pidFile.Remove()
    77  		return nil, errors.WithStack(err)
    78  	}
    79  
    80  	return r, nil
    81  }
    82  
    83  func (r *Runner) Run() error {
    84  	lw, err := r.pidFile.LogWriter()
    85  	if err != nil {
    86  		return err
    87  	}
    88  	logger := zerolog.New(lw).With().Str("source", "runner").Str("cmd", r.pidFile.String()).Timestamp().Logger()
    89  
    90  	if r.mode == RunnerModeLoopDetached {
    91  		if !reexec.IsChild() {
    92  			varDir := filepath.Join(util.GetHomeDir(), "var")
    93  			if err := os.MkdirAll(varDir, 0755); err != nil {
    94  				return errors.Wrap(err, "Could not create status file")
    95  			}
    96  			err := reexec.Background(varDir)
    97  			if err == nil {
    98  				return RunnerWentToBackground{}
    99  			}
   100  
   101  			if _, isExitCoder := err.(console.ExitCoder); isExitCoder {
   102  				return err
   103  			}
   104  			terminal.Printfln("Impossible to go to the background: %s", err)
   105  			terminal.Println("Continue in foreground")
   106  			r.mode = RunnerModeOnce
   107  		} else {
   108  			if err := reexec.NotifyForeground("boot"); err != nil {
   109  				return console.Exit(fmt.Sprintf("Unable to go to the background: %s, aborting", err), 1)
   110  			}
   111  		}
   112  	}
   113  
   114  	// We want those NOT to be buffered on purpose to be able to skip events when restarting
   115  	cmdExitChan := make(chan error) // receives command exit status, allow to cmd.Wait() in non-blocking way
   116  	restartChan := make(chan bool)  // receives requests to restart the command
   117  	sigChan := make(chan os.Signal, 1)
   118  	signal.Notify(sigChan, os.Interrupt, syscall.SIGQUIT, syscall.SIGTERM)
   119  	defer signal.Stop(sigChan)
   120  
   121  	if len(r.pidFile.Watched) > 0 {
   122  		// Make the channel buffered to ensure no event is dropped. Notify will drop
   123  		// an event if the receiver is not able to keep up the sending pace.
   124  		c := make(chan inotify.EventInfo, 10)
   125  		defer inotify.Stop(c)
   126  
   127  		go func() {
   128  			for {
   129  				event := <-c
   130  
   131  				// ignore vim temporary files events
   132  				if strings.HasSuffix(filepath.Ext(event.Path()), "~") {
   133  					continue
   134  				}
   135  
   136  				logger.Debug().Msg("Got event: " + event.Event().String())
   137  
   138  				select {
   139  				case restartChan <- true:
   140  				default:
   141  				}
   142  			}
   143  		}()
   144  
   145  		for _, watched := range r.pidFile.Watched {
   146  			if fi, err := os.Stat(watched); err != nil {
   147  				continue
   148  			} else if fi.IsDir() {
   149  				logger.Info().Msg("Watching directory " + watched)
   150  				watched = filepath.Join(watched, "...")
   151  			} else {
   152  				logger.Info().Msg("Watching file " + watched)
   153  			}
   154  			if err := inotify.Watch(watched, c, inotify.All); err != nil {
   155  				return errors.Wrapf(err, `could not watch "%s"`, watched)
   156  			}
   157  		}
   158  	}
   159  
   160  	firstBoot := r.mode != RunnerModeOnce
   161  	looping := r.mode != RunnerModeOnce || len(r.pidFile.Watched) > 0
   162  
   163  	// duration is not really important here, we just need enough time to stop
   164  	// the timer to be sure no event is fired and got stocked in the channel
   165  	timer := time.NewTimer(time.Hour)
   166  	timer.Stop()
   167  
   168  	pid := os.Getpid()
   169  
   170  	for {
   171  		cmd, err := r.buildCmd()
   172  		if err != nil {
   173  			return errors.Wrap(err, "unable to build cmd")
   174  		}
   175  
   176  		if err := cmd.Start(); err != nil {
   177  			return errors.Wrapf(err, `command "%s" failed to start`, r.pidFile)
   178  		}
   179  
   180  		go func() { cmdExitChan <- cmd.Wait() }()
   181  
   182  		if firstBoot {
   183  			timer.Reset(RunnerReliefDuration)
   184  
   185  			if r.mode == RunnerModeLoopDetached {
   186  				reexec.NotifyForeground("started")
   187  			}
   188  			logger.Debug().Msg("Waiting for channels (first boot)")
   189  			select {
   190  			case err := <-cmdExitChan:
   191  				logger.Debug().Msg("Received exit (first boot)")
   192  				if err != nil {
   193  					return errors.Wrapf(err, `command "%s" failed early`, r.pidFile)
   194  				}
   195  
   196  				timer.Stop()
   197  				// when the command is really fast to run, it will be already
   198  				// done here, so we need to forward exit status as if it has
   199  				// finished later one
   200  				go func() { cmdExitChan <- err }()
   201  			case <-timer.C:
   202  				logger.Debug().Msg("Received timer message (first boot)")
   203  			}
   204  		}
   205  
   206  		if r.mode == RunnerModeLoopAttached {
   207  			pid = cmd.Process.Pid
   208  		}
   209  		if firstBoot || r.mode == RunnerModeLoopAttached {
   210  			if err := r.pidFile.Write(pid, 0, ""); err != nil {
   211  				return errors.Wrap(err, "unable to write pid file")
   212  			}
   213  		}
   214  		if firstBoot && r.mode == RunnerModeLoopDetached {
   215  			terminal.RemapOutput(cmd.Stdout, cmd.Stderr).SetDecorated(true)
   216  			reexec.NotifyForeground(reexec.UP)
   217  		}
   218  
   219  		firstBoot = false
   220  
   221  		select {
   222  		case sig := <-sigChan:
   223  			logger.Info().Msgf("Signal \"%s\" received, forwarding and exiting\n", sig)
   224  			err := cmd.Process.Signal(sig)
   225  			if err != nil && runtime.GOOS == "windows" && strings.Contains(err.Error(), "not supported by windows") {
   226  				return exec.Command("CMD", "/C", "TASKKILL", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run()
   227  			}
   228  			return err
   229  		case <-restartChan:
   230  			logger.Debug().Msg("Received restart")
   231  			// We use SIGTERM here because it's nicer and thus when we use our
   232  			// wrappers, signal will be nicely forwarded
   233  			cmd.Process.Signal(syscall.SIGTERM)
   234  			// we need to drain cmdExit channel to unblock cmd channel receiver
   235  			<-cmdExitChan
   236  		// Command exited
   237  		case err := <-cmdExitChan:
   238  			logger.Debug().Msg("Received exit")
   239  			if err == nil && r.SuccessHook != nil {
   240  				logger.Debug().Msg("Running success hook")
   241  				r.SuccessHook(r, cmd)
   242  			}
   243  
   244  			// Command is NOT set up to loop, stop here and remove the pidFile
   245  			// if the command is successful
   246  			if !looping {
   247  				if err != nil {
   248  					logger.Debug().Msg("Not looping, exiting with error")
   249  					return err
   250  				}
   251  
   252  				logger.Debug().Msg("Removing pid file")
   253  				return r.pidFile.Remove()
   254  			}
   255  
   256  			// Command is set up to restart on exit (usually PHP builtin server)
   257  			if r.AlwaysRestartOnExit {
   258  				logger.Debug().Msg("Looping")
   259  				// In case of error we want to wait up-to 5 seconds before
   260  				// restarting the command, this avoids overloading the system with a
   261  				// failing command
   262  				if err != nil {
   263  					logger.Error().Msgf(`command exited: %s, waiting 5 seconds before restarting it`, err)
   264  					timer.Reset(5 * time.Second)
   265  				} else {
   266  					logger.Error().Msg("command exited, restarting it immediately")
   267  				}
   268  				continue
   269  			}
   270  
   271  			return nil
   272  		}
   273  
   274  		logger.Info().Msg("Restarting command")
   275  	}
   276  }
   277  
   278  func (r *Runner) buildCmd() (*exec.Cmd, error) {
   279  	cmd := exec.Command(r.binary, r.pidFile.Args[1:]...)
   280  	cmd.Env = os.Environ()
   281  	cmd.Dir = r.pidFile.Dir
   282  
   283  	if err := buildCmd(cmd, r.mode == RunnerModeOnce && terminal.Stdin.IsInteractive()); err != nil {
   284  		return nil, err
   285  	}
   286  
   287  	if r.mode == RunnerModeOnce {
   288  		cmd.Stdout = os.Stdout
   289  		cmd.Stderr = os.Stderr
   290  		cmd.Stdin = os.Stdin
   291  	} else if logWriter, err := r.pidFile.LogWriter(); err != nil {
   292  		return nil, errors.WithStack(err)
   293  	} else {
   294  		cmd.Stdout = logWriter
   295  		cmd.Stderr = logWriter
   296  	}
   297  
   298  	if r.BuildCmdHook != nil {
   299  		if err := r.BuildCmdHook(cmd); err != nil {
   300  			return cmd, errors.WithStack(err)
   301  		}
   302  	}
   303  
   304  	return cmd, nil
   305  }