diff --git a/README.md b/README.md index 21986f1..bd86f64 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,29 @@ running processes, wait 5s, and then send a `SIGKILL` to all remaining processes It runs the processes in `Procfile.dev` "as-is"; It does not load environment variables from `.env` before running. +## File watching + +Add `# watch: PATTERNS` to automatically restart a process when files change: + +```txt +web: bundle exec ruby cmd/web.rb # watch: lib/**/*.rb,ui/**/*.haml +esbuild: bun run buildwatch +``` + +Patterns are relative to the directory containing `Procfile.dev`. +Glob patterns support `*` (single directory) and `**` (recursive). +Processes without a watch annotation run without file watching. +Changes are debounced (500ms) to avoid rapid restarts. +On change, procman sends SIGINT, waits for the process to exit, then restarts it. + `procman` is distributed via Go source code, not via a Homebrew package. -`procman` depends on [github.com/creack/pty](https://github.com/creack/pty/tree/master) -for a PTY interface. +`procman` depends on: + +- [creack/pty](https://github.com/creack/pty) for PTY interface +- [fsnotify/fsnotify](https://github.com/fsnotify/fsnotify) for file watching +- [bmatcuk/doublestar](https://github.com/bmatcuk/doublestar) for glob patterns ## Developing diff --git a/go.mod b/go.mod index fafac84..33afd06 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,10 @@ module github.com/croaky/procman go 1.25 -require github.com/creack/pty v1.1.24 +require ( + github.com/bmatcuk/doublestar/v4 v4.10.0 + github.com/creack/pty v1.1.24 + github.com/fsnotify/fsnotify v1.9.0 +) + +require golang.org/x/sys v0.13.0 // indirect diff --git a/go.sum b/go.sum index 5310a43..32f55e7 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,8 @@ +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/main.go b/main.go index d7c7152..06d16fc 100644 --- a/main.go +++ b/main.go @@ -16,26 +16,33 @@ import ( "os" "os/exec" "os/signal" + "path/filepath" "regexp" "strings" "sync" "syscall" "time" + "github.com/bmatcuk/doublestar/v4" "github.com/creack/pty" + "github.com/fsnotify/fsnotify" ) -const timeout = 5 * time.Second +const ( + timeout = 5 * time.Second + debounce = 500 * time.Millisecond +) var ( colors = []int{2, 3, 4, 5, 6, 42, 130, 103, 129, 108} - procfileRe = regexp.MustCompile(`^([\w-]+):\s+(.+)$`) + procfileRe = regexp.MustCompile(`^([\w-]+):\s+(.+?)(?:\s+#\s*watch:\s*(.+))?$`) ) // procDef represents a single line in the procfile, with a name and command type procDef struct { - name string - cmd string + name string + cmd string + watchPatterns []string } // manager handles overall process management @@ -43,16 +50,31 @@ type manager struct { output *output procs []*process procWg sync.WaitGroup - done chan bool + done chan struct{} interrupted chan os.Signal + + // shared file watching + fsw *fsnotify.Watcher + watchDir string + watchStopCh chan struct{} + watchOnce sync.Once + watchMu sync.Mutex + watchTimers map[*process]*time.Timer } // process represents an individual process to be managed type process struct { *exec.Cmd - name string - color int - output *output + name string + cmdStr string + color int + output *output + watchPatterns []string + mu sync.Mutex + started bool + stopped bool + restarting bool + done chan struct{} // closed when run() completes } // output manages the output display of processes @@ -88,6 +110,8 @@ func main() { mgr.runProcess(proc) } + mgr.setupWatchers() + go mgr.waitForExit() mgr.procWg.Wait() } @@ -129,7 +153,7 @@ func parseProcfile(r io.Reader) ([]procDef, error) { } params := procfileRe.FindStringSubmatch(line) - if len(params) != 3 { + if len(params) < 3 { continue } @@ -138,7 +162,16 @@ func parseProcfile(r io.Reader) ([]procDef, error) { return nil, fmt.Errorf("duplicate process name %s in Procfile.dev", name) } names[name] = true - defs = append(defs, procDef{name: name, cmd: cmd}) + + var patterns []string + if len(params) > 3 && params[3] != "" { + for _, p := range strings.Split(params[3], ",") { + if p = strings.TrimSpace(p); p != "" { + patterns = append(patterns, p) + } + } + } + defs = append(defs, procDef{name: name, cmd: cmd, watchPatterns: patterns}) } if err := scanner.Err(); err != nil { @@ -152,22 +185,25 @@ func parseProcfile(r io.Reader) ([]procDef, error) { // setupProcesses creates and initializes processes based on the given procDefs. func (mgr *manager) setupProcesses(defs []procDef, procNames []string) error { - defMap := make(map[string]string) + defMap := make(map[string]procDef) for _, def := range defs { - defMap[def.name] = def.cmd + defMap[def.name] = def } for i, name := range procNames { - cmd, ok := defMap[name] + def, ok := defMap[name] if !ok { - return fmt.Errorf("No process named %s in Procfile.dev\n", name) + return fmt.Errorf("no process named %s in Procfile.dev", name) } proc := &process{ - Cmd: exec.Command("/bin/sh", "-c", cmd), - name: name, - color: colors[i%len(colors)], - output: mgr.output, + Cmd: exec.Command("/bin/sh", "-c", def.cmd), + name: name, + cmdStr: def.cmd, + color: colors[i%len(colors)], + output: mgr.output, + watchPatterns: def.watchPatterns, + done: make(chan struct{}), } mgr.procs = append(mgr.procs, proc) } @@ -176,7 +212,7 @@ func (mgr *manager) setupProcesses(defs []procDef, procNames []string) error { // setupSignalHandling configures handling of interrupt signals. func (mgr *manager) setupSignalHandling() { - mgr.done = make(chan bool, len(mgr.procs)) + mgr.done = make(chan struct{}, len(mgr.procs)) mgr.interrupted = make(chan os.Signal, 1) signal.Notify(mgr.interrupted, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) } @@ -186,7 +222,14 @@ func (mgr *manager) runProcess(proc *process) { mgr.procWg.Add(1) go func() { defer mgr.procWg.Done() - defer func() { mgr.done <- true }() + defer func() { + proc.mu.Lock() + restarting := proc.restarting + proc.mu.Unlock() + if !restarting { + mgr.done <- struct{}{} + } + }() proc.run() }() } @@ -198,6 +241,8 @@ func (mgr *manager) waitForExit() { case <-mgr.interrupted: } + mgr.stopWatcher() + for _, proc := range mgr.procs { proc.interrupt() } @@ -214,7 +259,9 @@ func (mgr *manager) waitForExit() { // running inspects the process to see if it is currently running. func (proc *process) running() bool { - return proc.Process != nil && proc.ProcessState == nil + proc.mu.Lock() + defer proc.mu.Unlock() + return proc.started && !proc.stopped } // run starts the execution of the process and handles its output. @@ -226,6 +273,12 @@ func (proc *process) run() { proc.output.pipeOutput(proc) defer proc.output.closePipe(proc) + defer func() { + proc.mu.Lock() + proc.stopped = true + proc.mu.Unlock() + close(proc.done) + }() if err := proc.Cmd.Wait(); err != nil { proc.output.writeErr(proc, err) @@ -271,6 +324,10 @@ func (out *output) pipeOutput(proc *process) { os.Exit(1) } + proc.mu.Lock() + proc.started = true + proc.mu.Unlock() + out.mutex.Lock() out.pipes[proc] = ptyFile out.mutex.Unlock() @@ -290,6 +347,7 @@ func (out *output) pipeOutput(proc *process) { func (out *output) closePipe(proc *process) { out.mutex.Lock() ptyFile := out.pipes[proc] + delete(out.pipes, proc) out.mutex.Unlock() if ptyFile != nil { @@ -318,3 +376,203 @@ func (out *output) writeLine(proc *process, p []byte) { func (out *output) writeErr(proc *process, err error) { out.writeLine(proc, []byte(fmt.Sprintf("\033[0;31m%v\033[0m", err))) } + +// setupWatchers creates a single watcher for all processes with watch patterns. +func (mgr *manager) setupWatchers() { + has := false + for _, p := range mgr.procs { + if len(p.watchPatterns) > 0 { + has = true + break + } + } + if !has { + return + } + + fsw, err := fsnotify.NewWatcher() + if err != nil { + if len(mgr.procs) > 0 { + mgr.procs[0].output.writeErr(mgr.procs[0], fmt.Errorf("watch setup: %v", err)) + } + return + } + mgr.fsw = fsw + mgr.watchDir, _ = os.Getwd() + mgr.watchStopCh = make(chan struct{}) + mgr.watchTimers = make(map[*process]*time.Timer) + + dirs := make(map[string]bool) + for _, proc := range mgr.procs { + for _, pattern := range proc.watchPatterns { + matches, err := doublestar.Glob(os.DirFS("."), pattern) + if err != nil { + proc.output.writeErr(proc, fmt.Errorf("glob %q: %v", pattern, err)) + continue + } + for _, m := range matches { + dirs[filepath.Dir(m)] = true + } + base := nonGlobBaseDir(pattern) + _ = filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + dirs[path] = true + } + return nil + }) + } + } + count := 0 + for d := range dirs { + if err := mgr.fsw.Add(d); err != nil { + if len(mgr.procs) > 0 { + mgr.procs[0].output.writeErr(mgr.procs[0], fmt.Errorf("watch %s: %v", d, err)) + } + continue + } + count++ + } + if count == 0 && len(dirs) > 0 { + mgr.fsw.Close() + mgr.fsw = nil + return + } + go mgr.watchLoop() +} + +func nonGlobBaseDir(pattern string) string { + // find first index of any glob metachar + idx := -1 + for i, ch := range pattern { + if ch == '*' || ch == '?' || ch == '[' { + idx = i + break + } + } + if idx <= 0 { + return "." + } + return filepath.Dir(pattern[:idx]) +} + +func (mgr *manager) matchPatterns(patterns []string, path string) bool { + if mgr.watchDir != "" { + if rel, err := filepath.Rel(mgr.watchDir, path); err == nil { + path = rel + } + } + for _, pattern := range patterns { + matched, err := doublestar.Match(pattern, path) + if err != nil { + if len(mgr.procs) > 0 { + mgr.procs[0].output.writeErr(mgr.procs[0], fmt.Errorf("pattern %q: %v", pattern, err)) + } + continue + } + if matched { + return true + } + } + return false +} + +func (mgr *manager) watchLoop() { + for { + select { + case <-mgr.watchStopCh: + mgr.stopAllTimers() + if mgr.fsw != nil { + mgr.fsw.Close() + } + return + case event, ok := <-mgr.fsw.Events: + if !ok { + return + } + if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { + continue + } + for _, proc := range mgr.procs { + if len(proc.watchPatterns) == 0 { + continue + } + if mgr.matchPatterns(proc.watchPatterns, event.Name) { + mgr.scheduleRestart(proc) + } + } + case err, ok := <-mgr.fsw.Errors: + if !ok { + return + } + if len(mgr.procs) > 0 { + mgr.procs[0].output.writeErr(mgr.procs[0], err) + } + } + } +} + +func (mgr *manager) scheduleRestart(proc *process) { + mgr.watchMu.Lock() + defer mgr.watchMu.Unlock() + + if t := mgr.watchTimers[proc]; t != nil { + t.Stop() + } + mgr.watchTimers[proc] = time.AfterFunc(debounce, func() { + mgr.restart(proc) + mgr.watchMu.Lock() + delete(mgr.watchTimers, proc) + mgr.watchMu.Unlock() + }) +} + +func (mgr *manager) stopAllTimers() { + mgr.watchMu.Lock() + defer mgr.watchMu.Unlock() + for p, t := range mgr.watchTimers { + t.Stop() + delete(mgr.watchTimers, p) + } +} + +func (mgr *manager) stopWatcher() { + mgr.watchOnce.Do(func() { + if mgr.watchStopCh != nil { + close(mgr.watchStopCh) + } + }) +} + +func (mgr *manager) restart(p *process) { + p.mu.Lock() + if p.restarting || !p.started || p.stopped { + p.mu.Unlock() + return + } + p.restarting = true + done := p.done + p.mu.Unlock() + + p.output.writeLine(p, []byte("\033[0;33mrestarting...\033[0m")) + p.interrupt() + + select { + case <-done: + case <-time.After(timeout): + p.kill() + <-done + } + + p.mu.Lock() + p.Cmd = exec.Command("/bin/sh", "-c", p.cmdStr) + p.done = make(chan struct{}) + p.started = false + p.stopped = false + p.restarting = false + p.mu.Unlock() + + mgr.runProcess(p) +} diff --git a/main_test.go b/main_test.go index 29e1ec7..9418841 100644 --- a/main_test.go +++ b/main_test.go @@ -1,10 +1,12 @@ package main import ( + "bufio" "bytes" "fmt" "os" "os/exec" + "path/filepath" "reflect" "strings" "testing" @@ -104,6 +106,23 @@ func TestParseProcfile(t *testing.T) { want: nil, wantErr: true, }, + { + name: "With watch patterns", + content: "web: bundle exec ruby cmd/web.rb # watch: lib/**/*.rb,ui/**/*.haml", + want: []procDef{ + {name: "web", cmd: "bundle exec ruby cmd/web.rb", watchPatterns: []string{"lib/**/*.rb", "ui/**/*.haml"}}, + }, + wantErr: false, + }, + { + name: "Mixed with and without watch", + content: "web: ruby web.rb # watch: *.rb\nesbuild: bun build", + want: []procDef{ + {name: "web", cmd: "ruby web.rb", watchPatterns: []string{"*.rb"}}, + {name: "esbuild", cmd: "bun build"}, + }, + wantErr: false, + }, } for _, tt := range tests { @@ -171,19 +190,26 @@ func TestSetupSignalHandling(t *testing.T) { func TestProcessRunning(t *testing.T) { cmd := exec.Command("sleep", "1") - proc := &process{Cmd: cmd, output: &output{}} + out := &output{pipes: make(map[*process]*os.File)} + proc := &process{Cmd: cmd, output: out, done: make(chan struct{})} if proc.running() { t.Errorf("expected process to not be running before start") } - if err := cmd.Start(); err != nil { - t.Fatalf("failed to start process: %v", err) - } + // Start via run path to set started/stopped flags + go proc.run() + time.Sleep(200 * time.Millisecond) if !proc.running() { t.Errorf("expected process to be running after start") } + + // Cleanup: kill the process and wait for run() to complete + if proc.Process != nil { + proc.Process.Kill() + } + <-proc.done } func TestInit(t *testing.T) { @@ -246,7 +272,66 @@ func TestWriteErr(t *testing.T) { } } -// TestProcmanIntegration tests the full procman workflow. +func TestMatchesPattern(t *testing.T) { + tests := []struct { + name string + patterns []string + path string + want bool + }{ + { + name: "Simple glob match", + patterns: []string{"*.rb"}, + path: "app.rb", + want: true, + }, + { + name: "Simple glob no match", + patterns: []string{"*.rb"}, + path: "app.go", + want: false, + }, + { + name: "Recursive glob match", + patterns: []string{"lib/**/*.rb"}, + path: "lib/models/user.rb", + want: true, + }, + { + name: "Recursive glob no match wrong dir", + patterns: []string{"lib/**/*.rb"}, + path: "app/models/user.rb", + want: false, + }, + { + name: "Multiple patterns first matches", + patterns: []string{"*.rb", "*.haml"}, + path: "view.haml", + want: true, + }, + { + name: "Multiple patterns none match", + patterns: []string{"*.rb", "*.haml"}, + path: "style.css", + want: false, + }, + { + name: "Directory pattern match", + patterns: []string{"ui/**/*.haml"}, + path: "ui/views/index.haml", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mgr := &manager{watchDir: ""} // empty means path is already relative + if got := mgr.matchPatterns(tt.patterns, tt.path); got != tt.want { + t.Errorf("matchesPattern(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} func TestProcmanIntegration(t *testing.T) { content := "echo: echo 'hello'\nsleep: sleep 10" if err := os.WriteFile("Procfile.dev", []byte(content), 0644); err != nil { @@ -269,3 +354,61 @@ func TestProcmanIntegration(t *testing.T) { t.Fatalf("procman did not exit cleanly: %v", err) } } + +func TestRestartOnFileChange(t *testing.T) { + dir := t.TempDir() + procfile := "echo: echo run; sleep 5 # watch: *.rb\n" + if err := os.WriteFile(filepath.Join(dir, "Procfile.dev"), []byte(procfile), 0644); err != nil { + t.Fatalf("write procfile: %v", err) + } + // Create an initial .rb file so the watcher has something to watch + if err := os.WriteFile(filepath.Join(dir, "init.rb"), []byte(""), 0644); err != nil { + t.Fatalf("create init.rb: %v", err) + } + + cwd, _ := os.Getwd() + exe := filepath.Join(cwd, "procman") + cmd := exec.Command(exe, "echo") + cmd.Dir = dir + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("StdoutPipe: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("start procman: %v", err) + } + defer func() { + _ = cmd.Process.Signal(os.Interrupt) + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-done: + case <-time.After(6 * time.Second): + _ = cmd.Process.Kill() + <-done + } + }() + + sc := bufio.NewScanner(stdout) + restarted := make(chan struct{}) + go func() { + for sc.Scan() { + if strings.Contains(sc.Text(), "restarting...") { + close(restarted) + return + } + } + }() + + // Trigger a change by modifying the existing file + time.Sleep(500 * time.Millisecond) + if err := os.WriteFile(filepath.Join(dir, "init.rb"), []byte("puts :x\n"), 0644); err != nil { + t.Fatalf("modify file: %v", err) + } + + select { + case <-restarted: + case <-time.After(4 * time.Second): + t.Fatal("timeout waiting for restart") + } +}