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 }