From 54e5b0d116dda253f4b5cb1f4e123d821c171ff3 Mon Sep 17 00:00:00 2001 From: Patrick Koperwas Date: Mon, 29 Jan 2018 14:25:16 -0800 Subject: [PATCH] feat: Remove build step The build step was originally needed, because as much as I wanted to just have `go run main.go` be rerun on save, it turns out that was rather difficult to figure out how to do nicely. The issue was that while I could kill the `go run main.go`, I could not kill the child processes that it spawns, so the end user would be left with orphaned processes running that would hog system resources (like ports). So I did what all the other Go-based reloaders I had seen do, which is to add a build step to compile the program, then another step to run it. However, I wanted Tychus to be usable with other types of projects (like Ruby for example) which had no build steps. So the usage felt wrong - there were two camps. If you are in the "my program needs to be compiled camp", well then the setup + running are completely different from the dynamic language folks. And even then, there were some edge cases. Rust's Cargo for example, worked just fine using `cargo run` without a build step. So to unify everything, I am removing the build step. I did manage a way to make sure child and grandchild processes would die happily. So now for Go projects, the end user can just run `tychus run go run main.go`. The unfortunate part is that these changes makes it more difficult to port to Windows, so that work may have to come later. --- Gopkg.lock | 6 +- Gopkg.toml | 2 +- README.md | 116 +++++++++++++++--------------- cmd/init.go | 40 +++-------- cmd/run.go | 56 ++++++++------- tychus/builder.go | 80 --------------------- tychus/configuration.go | 30 ++------ tychus/orchestrator.go | 152 +++++++++++++++------------------------- tychus/runner.go | 60 ++++++++-------- tychus/watcher.go | 4 +- 10 files changed, 200 insertions(+), 346 deletions(-) delete mode 100644 tychus/builder.go diff --git a/Gopkg.lock b/Gopkg.lock index 5f42cdb..4c44699 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -4,8 +4,8 @@ [[projects]] name = "github.com/devlocker/devproxy" packages = ["devproxy"] - revision = "3983aef7dfa5997840de28c4b10c15105d72cd72" - version = "0.2.0" + revision = "9492883c201c9b27979e7c8c64994add763d74d0" + version = "0.2.1" [[projects]] name = "github.com/fatih/color" @@ -64,6 +64,6 @@ [solve-meta] analyzer-name = "dep" analyzer-version = 1 - inputs-digest = "34443a1952ecd2e03009897a75839408a7e2632098843ec2d5d5767866088af7" + inputs-digest = "e595b55d436a5778bcf05f96420141d3d8dd9b2123864f3469dd7896c3699de9" solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index e79e732..248aa0f 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -1,6 +1,6 @@ [[constraint]] name = "github.com/devlocker/devproxy" - version = "0.2.0" + version = "0.2.1" [[constraint]] name = "github.com/fatih/color" diff --git a/README.md b/README.md index c8477ce..7f0764b 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,10 @@ will watch your filesystem for changes and automatically recompile and restart code on change. `tychus` is language agnostic - it can be configured to work with just about -anything: Go, Rust, Ruby, Python, etc. Should you desire you can use `tychus` -as a proxy to your application. The proxy is pretty smart - it will pause -requests while your app rebuilds and won't let you run into that super annoying -case where you refresh your webpage after your app boots, but before it can -serve a request. +anything: Go, Rust, Ruby, Python, etc. + +Should you desire you can use `tychus` as a proxy to your application. It will +pause requests while your application rebuilds & restarts. ## Installation @@ -29,82 +28,79 @@ Assuming you have a working Go environment and `GOPATH/bin` is in your `PATH` go get github.com/devlocker/tychus ``` +### Windows +Currently isn't supported :( + ## Getting Started You will need to create a `.tychus.yml` file configuration file. Easiest way is -to generate one with: +to generate one is with: ``` $ tychus init ``` +Double check your generated `.tychus.yml` config to make sure it knows which +file extensions to watch. + ## Usage +Usage is simple, `tychus run` and then your command. On a filesystem change that +command will be rerun. + ``` -tychus run +// Go +tychus run go run main.go + +// Rust +tychus run cargo run + +// Ruby +tychus ruby myapp.rb + +// Shell Commands +tychus run ls ``` -Want to pass additional arguments? +Need to pass flags? Stick the command in quotes ``` -tychus run bundle exec ruby myapp.rb +tychus run "ruby myapp.rb -e development" ``` -Need to pass flags? The following are equivalent: +Complicated command? Stick it in quotes ``` -# Yep, that will just run "ls -al" on any file change with the build step -# disabled. You can really run anything you want. -tychus run "ls -al" -tychus run ls --with=-al -tychus run ls -w -al +tychus run "go build -o my-bin && echo 'Built Binary' && ./my-bin" ``` + ## Configuration ```yaml -# Settings for the file watcher -watch: - # List of extentions to watch. A change to a file with one of these extensions - # will trigger a fresh of your application. - extensions: - - .go - # List of folders to not watch. - ignore: - - node_modules - - tmp - - log - - vendor - -# Build settings. -build: - # Disable this if you don't have a compile step (Ruby, Python, etc.). - enabled: false - # Command to run to rebuild your binary. Tychus will automatically tack on a - # -o bin_name so it can be omitted. - build_command: go build -i - # Name of binary that gets built. - bin_name: tychus-bin - # Where to put your built binary. - target_path: tmp - -# Proxy settings. -proxy: - # If not enabled, proxy will not start. - enabled: true - # Port your application runs on. NOTE: a PORT environment will take overwrite - # whatever you put here. - app_port: 3000 - # Port to run the proxy on. - proxy_port: 4000 - # In seconds, how long the proxy will attempt to proxy a request until it - # gives up and returns a 502. - timeout: 10 +# List of extentions to watch. A change to a file with one of these extensions +# will trigger a fresh of your application. +extensions: +- .go + +# List of folders to not watch. Too many watched files / folders can slow things +down, so try and ignore as much as possible. +ignore: +- node_modules +- tmp +- log +- vendor + +# If not enabled, proxy will not start. +proxy_enabled: true + +# Port proxy runs on. +proxy_port: 4000 + +# Port your application runs on. NOTE: a PORT environment will take overwrite +# whatever you put here. +app_port: 3000 + +# In seconds, how long the proxy will attempt to proxy a request until it +# gives up and returns a 502. +timeout: 10 ``` - -### Sample Configurations + Instructions: - -* [Go](https://github.com/devlocker/tychus/wiki/Example:-Go) -* [Ruby + Sinatra](https://github.com/devlocker/tychus/wiki/Example:-Ruby---Sinatra) -* [Rust](https://github.com/devlocker/tychus/wiki/Example:-Rust) -* [Rust with Cargo](https://github.com/devlocker/tychus/wiki/Example:-Rust-with-Cargo) - diff --git a/cmd/init.go b/cmd/init.go index 5ec6075..bd14c0a 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -72,22 +72,12 @@ func detectLangauge(dir string) (*tychus.Configuration, error) { } c := &tychus.Configuration{ - Build: tychus.BuildConfig{ - BuildCommand: "go build -i", - BinName: "tychus-bin", - Enabled: true, - TargetPath: "tmp/", - }, - Watch: tychus.WatchConfig{ - Extensions: []string{".go"}, - Ignore: []string{"node_modules", "tmp", "log", "vendor"}, - }, - Proxy: tychus.ProxyConfig{ - Enabled: true, - AppPort: 3000, - ProxyPort: 4000, - Timeout: 10, - }, + Extensions: []string{".go"}, + Ignore: []string{"node_modules", "tmp", "log", "vendor"}, + ProxyEnabled: true, + ProxyPort: 4000, + AppPort: 3000, + Timeout: 10, } // Go Project? @@ -103,10 +93,7 @@ func detectLangauge(dir string) (*tychus.Configuration, error) { for _, f := range files { ext := filepath.Ext(f.Name()) if f.Name() == "Gemfile" || ext == ".rb" { - c.Build.Enabled = false - c.Build.BuildCommand = "" - c.Build.BinName = "" - c.Watch.Extensions = []string{".rb"} + c.Extensions = []string{".rb"} return c, nil } } @@ -115,10 +102,7 @@ func detectLangauge(dir string) (*tychus.Configuration, error) { for _, f := range files { ext := filepath.Ext(f.Name()) if ext == ".py" { - c.Build.Enabled = false - c.Build.BuildCommand = "" - c.Build.BinName = "" - c.Watch.Extensions = []string{".py"} + c.Extensions = []string{".py"} return c, nil } } @@ -127,8 +111,7 @@ func detectLangauge(dir string) (*tychus.Configuration, error) { for _, f := range files { ext := filepath.Ext(f.Name()) if f.Name() == "Cargo.toml" || ext == ".rs" { - c.Build.BuildCommand = "rustc main.rs" - c.Watch.Extensions = []string{".rs"} + c.Extensions = []string{".rs"} return c, nil } } @@ -137,10 +120,7 @@ func detectLangauge(dir string) (*tychus.Configuration, error) { for _, f := range files { ext := filepath.Ext(f.Name()) if f.Name() == "package.json" || ext == ".js" { - c.Build.Enabled = false - c.Build.BuildCommand = "" - c.Build.BinName = "" - c.Watch.Extensions = []string{".js"} + c.Extensions = []string{".js"} return c, nil } } diff --git a/cmd/run.go b/cmd/run.go index 6dfb6c6..d9aa6b3 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -2,62 +2,70 @@ package cmd import ( "os" + "os/signal" "strconv" - "strings" + "syscall" "github.com/devlocker/tychus/tychus" "github.com/fatih/color" "github.com/spf13/cobra" ) -var with string - func init() { rootCmd.AddCommand(runCmd) - runCmd.Flags().StringVarP(&with, "with", "w", "", "extra options to run with") } var runCmd = &cobra.Command{ Use: "run", Short: "Reloads your application as you make changes to source files.", Run: func(cmd *cobra.Command, args []string) { - if len(with) > 1 { - args = append(args, strings.Fields(with)...) - } - start(args) }, } func start(args []string) { - c := &tychus.Configuration{} - c.Logger = tychus.NewLogger(debug) + stop := make(chan os.Signal, 1) + signal.Notify( + stop, + os.Interrupt, + syscall.SIGHUP, + syscall.SIGINT, + syscall.SIGTERM, + syscall.SIGQUIT, + ) + // Load configuration and use default logger + c := &tychus.Configuration{} err := c.Load(configFile) if err != nil { c.Logger.Fatal(err.Error()) } - c.Logger.Printf( - "Starting: build [%v], proxy [%v]", - isEnabledStr(c.Build.Enabled), - isEnabledStr(c.Proxy.Enabled), - ) + c.Logger = tychus.NewLogger(debug) // If PORT is set, use that instead of AppPort. For things like foreman // where ports are automatically assigned. - port := os.Getenv("PORT") - if len(port) > 0 { - appPort, err := strconv.Atoi(port) - if err == nil { - c.Proxy.AppPort = appPort + port, ok := os.LookupEnv("PORT") + if ok { + if appPort, err := strconv.Atoi(port); err == nil { + c.AppPort = appPort } } - err = tychus.Start(args, c) - if err != nil { - c.Logger.Fatal(err.Error()) - } + o := tychus.New(args, c) + + // Run tychus + go func() { + err = o.Start() + if err != nil { + o.Stop() + c.Logger.Fatal(err.Error()) + } + }() + + <-stop + // Have to call `Stop` + o.Stop() } func isEnabledStr(b bool) string { diff --git a/tychus/builder.go b/tychus/builder.go deleted file mode 100644 index e05130d..0000000 --- a/tychus/builder.go +++ /dev/null @@ -1,80 +0,0 @@ -package tychus - -import ( - "bytes" - "io" - "os/exec" - "path" - "strings" -) - -type builder struct { - rebuild chan bool - events chan event - buildCmd []string - cmd *exec.Cmd -} - -func newBuilder(c *Configuration) *builder { - var buildCmd []string - - if c.Build.Enabled { - buildCmd = append( - strings.Fields(c.Build.BuildCommand), - "-o", - path.Join(c.Build.TargetPath, c.Build.BinName), - ) - } - - return &builder{ - rebuild: make(chan bool), - events: make(chan event), - buildCmd: buildCmd, - } -} - -// Wait for rebuild messages. Once received, rebuilds the binary. Should -// another file system event occur while a build is running, the build will be -// aborted. This is to prevent the case where the user saves, triggering a -// rebuild then saves in while the build is occuring, causing another process -// to start or a binary is reloaded with now stale code. -func (b *builder) start(c *Configuration) { - for { - <-b.rebuild - - go func() { - if b.cmd != nil && b.cmd.Process != nil { - b.cmd.Process.Kill() - } - - c.Logger.Debugf("Building: %v", strings.Join(b.buildCmd, " ")) - - b.cmd = exec.Command(b.buildCmd[0], b.buildCmd[1:]...) - - var stderr bytes.Buffer - b.cmd.Stdout = nil - b.cmd.Stderr = io.MultiWriter(&stderr) - - err := b.cmd.Start() - if err != nil { - b.events <- event{info: stderr.String(), op: errored} - return - } - - if err = b.cmd.Wait(); err != nil { - // If ProcessState exists, means process was not aborted by - // another go routine - just failed to compile or some other - // error that the user should be presented with. - if b.cmd.ProcessState != nil { - b.events <- event{info: stderr.String(), op: errored} - } - - return - } - - b.cmd = nil - - b.events <- event{info: "Rebuilt", op: rebuilt} - }() - } -} diff --git a/tychus/configuration.go b/tychus/configuration.go index c5df11e..256e89a 100644 --- a/tychus/configuration.go +++ b/tychus/configuration.go @@ -8,29 +8,13 @@ import ( ) type Configuration struct { - Watch WatchConfig `yaml:"watch"` - Build BuildConfig `yaml:"build'` - Proxy ProxyConfig `yaml:"proxy"` - Logger Logger `yaml:"-"` -} - -type BuildConfig struct { - Enabled bool `yaml:"enabled"` - BuildCommand string `yaml:"build_command"` - BinName string `yaml:"bin_name"` - TargetPath string `yaml:"target_path"` -} - -type WatchConfig struct { - Extensions []string `yaml:"extensions"` - Ignore []string `yaml:"ignore"` -} - -type ProxyConfig struct { - Enabled bool `yaml:"enabled"` - AppPort int `yaml:"app_port"` - ProxyPort int `yaml:"proxy_port"` - Timeout int `yaml:"timeout"` + Extensions []string `yaml:"extensions"` + Ignore []string `yaml:"ignore"` + ProxyEnabled bool `yaml:"proxy_enabled"` + ProxyPort int `yaml:"proxy_port"` + AppPort int `yaml:"app_port"` + Timeout int `yaml:"timeout"` + Logger Logger `yaml:"-"` } func (c *Configuration) Write(path string) error { diff --git a/tychus/orchestrator.go b/tychus/orchestrator.go index 518051e..63506bf 100644 --- a/tychus/orchestrator.go +++ b/tychus/orchestrator.go @@ -5,126 +5,90 @@ // Unlike other application reloaders written in Go, Tychus is language // agnostic. It can be used with Go, Rust, Python, Ruby, scripts, etc. // -// Tychus has 3 parts to it's configuration. -// -// 1. Watch: configures what extensions to watch in what directories. A change -// to a watched file will trigger an application reload. -// -// 2. Build: if enabled, adds a build step (compile) after a file system change -// is detected. -// -// 3. Proxy: if enabled, will serve an application through a proxy. This can -// help mitigate annoyances like reloading your web page before the app server -// finishes booting (and getting an error page). +// If enabled, Tychus will serve an application through a proxy. This can help +// mitigate annoyances like reloading your web page before the app server +// finishes booting. Or attempting to make a request after the server starts, +// but before it is ready to accept requests. package tychus import ( - "errors" - "path/filepath" - "strings" - "github.com/devlocker/devproxy/devproxy" ) -func Start(args []string, c *Configuration) error { - args, err := formatArgs(args, c) - if err != nil { - return err +type Orchestrator struct { + config *Configuration + watcher *watcher + runner *runner + proxy *devproxy.Proxy +} + +func New(args []string, c *Configuration) *Orchestrator { + return &Orchestrator{ + config: c, + watcher: newWatcher(), + runner: newRunner(args), + proxy: devproxy.New(&devproxy.Configuration{ + AppPort: c.AppPort, + ProxyPort: c.ProxyPort, + Timeout: c.Timeout, + Logger: c.Logger, + }), } +} - w := newWatcher() - b := newBuilder(c) - r := newRunner(args) - p := devproxy.New(&devproxy.Configuration{ - AppPort: c.Proxy.AppPort, - ProxyPort: c.Proxy.ProxyPort, - Timeout: c.Proxy.Timeout, - Logger: c.Logger, - }) +// Starts Tychus. Any filesystem changes will cause the command passed in to be +// rerun. To avoid orphaning processes, make sure to call Stop before exiting. +func (o *Orchestrator) Start() error { + stop := make(chan error, 1) - go w.start(c) - go b.start(c) - go r.start(c) + go o.watcher.start(o.config) + go o.runner.start(o.config) - if c.Proxy.Enabled { - go p.Start() + if o.config.ProxyEnabled { + go func() { + err := o.proxy.Start() + if err != nil { + stop <- err + } + }() } - if c.Build.Enabled { - b.rebuild <- true - } else { - r.restart <- true - } + o.runner.restart <- true for { select { - // Watcher events - case event := <-w.events: - c.Logger.Debug(event) + case event := <-o.watcher.events: + o.config.Logger.Debug(event) switch event.op { case changed: - if c.Build.Enabled { - p.Command <- devproxy.Command{Cmd: devproxy.Pause} - b.rebuild <- true - } else { - r.restart <- true + o.proxy.Command <- devproxy.Command{ + Cmd: devproxy.Pause, } + o.runner.restart <- true } - // Builder events - case event := <-b.events: - c.Logger.Debug(event) - switch event.op { - case rebuilt: - c.Logger.Success("Build: Successful") - r.restart <- true - case errored: - c.Logger.Error("Build: Failed\n" + event.info) - p.Command <- devproxy.Command{Cmd: devproxy.Error, Data: event.info} - } - - // Runner events - case event := <-r.events: - c.Logger.Debug(event) + case event := <-o.runner.events: + o.config.Logger.Debug(event) switch event.op { case restarted: - p.Command <- devproxy.Command{Cmd: devproxy.Serve} + o.proxy.Command <- devproxy.Command{ + Cmd: devproxy.Serve, + } case errored: - p.Command <- devproxy.Command{Cmd: devproxy.Error, Data: event.info} + o.proxy.Command <- devproxy.Command{ + Cmd: devproxy.Error, + Data: event.info, + } } - } - } -} - -// Format arguments to take into account any build targets, bin names. And make -// sure to expand any quotes strings. -func formatArgs(args []string, c *Configuration) ([]string, error) { - if c.Build.Enabled { - binPath, err := filepath.Abs( - filepath.Join( - c.Build.TargetPath, - c.Build.BinName, - ), - ) - if err != nil { - return nil, err + case err := <-stop: + o.Stop() + return err } - - args = append([]string{binPath}, args...) - } - - // Can occur when running with build disabled. Since no binary to run, need - // some command to run, e.g. "ruby myapp.rb" - if len(args) < 1 { - return nil, errors.New("Not enough arguments") - } - - // Expand quoted strings - split by whitespace: - // []string{"ls -al"} => []string{"ls", "-al"}. - for i, a := range args { - args = append(args[:i], append(strings.Fields(a), args[i+1:]...)...) } +} - return args, nil +// Stops Tychus and forces any processes started by it that may be running. +func (o *Orchestrator) Stop() error { + return o.runner.kill() } diff --git a/tychus/runner.go b/tychus/runner.go index d6ac9dd..8479efa 100644 --- a/tychus/runner.go +++ b/tychus/runner.go @@ -7,7 +7,7 @@ import ( "os" "os/exec" "strings" - "time" + "syscall" ) type runner struct { @@ -42,32 +42,6 @@ func (r *runner) start(c *Configuration) { } } -// Kill running process. Give it a chance to exit cleanly, otherwise kill it -// after a certain timeout. -func (r *runner) kill() error { - if r.cmd != nil && r.cmd.Process != nil { - done := make(chan error, 1) - go func() { - r.cmd.Wait() - close(done) - }() - - r.cmd.Process.Signal(os.Interrupt) - - select { - case <-time.After(3 * time.Second): - if err := r.cmd.Process.Kill(); err != nil { - return err - } - case <-done: - } - - r.cmd = nil - } - - return nil -} - // Rerun the command. func (r *runner) rerun() error { if r.cmd != nil && r.cmd.ProcessState != nil && r.cmd.ProcessState.Exited() { @@ -77,10 +51,14 @@ func (r *runner) rerun() error { var stderr bytes.Buffer mw := io.MultiWriter(&stderr, os.Stderr) - r.cmd = exec.Command(r.args[0], r.args[1:]...) + r.cmd = exec.Command("/bin/sh", "-c", strings.Join(r.args, " ")) r.cmd.Stdout = os.Stdout r.cmd.Stderr = mw + // Setup a process group so when this process gets stopped, so do any child + // process that it may spawn. + r.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + err := r.cmd.Start() if err != nil { return errors.New(stderr.String()) @@ -89,7 +67,31 @@ func (r *runner) rerun() error { r.events <- event{info: "Restarted", op: restarted} if err := r.cmd.Wait(); err != nil { - r.events <- event{info: stderr.String(), op: errored} + // Program errored. Only log it if it exit status is postive, as status + // code -1 is when the process was killed. + if exiterr, ok := err.(*exec.ExitError); ok { + ws := exiterr.Sys().(syscall.WaitStatus) + if ws.ExitStatus() > 0 { + r.events <- event{info: stderr.String(), op: errored} + } + } + } + + return nil +} + +// Kill the existing process +func (r *runner) kill() error { + if r.cmd != nil && r.cmd.Process != nil { + // Kill the process group + pgid, err := syscall.Getpgid(r.cmd.Process.Pid) + if err == nil { + syscall.Kill(-pgid, syscall.SIGKILL) + } + + syscall.Kill(-r.cmd.Process.Pid, syscall.SIGKILL) + + r.cmd = nil } return nil diff --git a/tychus/watcher.go b/tychus/watcher.go index 7ee4a64..e22b51f 100644 --- a/tychus/watcher.go +++ b/tychus/watcher.go @@ -99,7 +99,7 @@ func (w *watcher) shouldSkipDir(path string, c *Configuration) bool { return true } - for _, f := range c.Watch.Ignore { + for _, f := range c.Ignore { f = strings.TrimSpace(f) f = strings.TrimRight(f, "/") @@ -115,7 +115,7 @@ func (w *watcher) shouldSkipDir(path string, c *Configuration) bool { func (w *watcher) isWatchedFile(path string, c *Configuration) bool { ext := filepath.Ext(path) - for _, e := range c.Watch.Extensions { + for _, e := range c.Extensions { if strings.TrimSpace(e) == ext { return true }