From 04f13681c39b80dc07923ff7c6e11c208d7e9df4 Mon Sep 17 00:00:00 2001 From: Dan Croak Date: Wed, 4 Feb 2026 11:29:20 -0500 Subject: [PATCH 1/3] watch: add file watching with automatic process restart Add watching to procman where it benefits projects of all languages rather than build it into Ruby or Go projects/frameworks. Syntax: add `# watch: PATTERNS` to Procfile.dev entries: web: bundle exec ruby cmd/web.rb # watch: lib/**/*.rb On file change matching a pattern, procman sends SIGINT to the process, waits for exit, and restarts. Changes are debounced (500ms) to avoid rapid restarts during multi-file saves. Processes without a watch annotation behave exactly as before. Co-Authored-By: Warp --- README.md | 14 +++ go.mod | 8 +- go.sum | 6 ++ main.go | 259 +++++++++++++++++++++++++++++++++++++++++++++++---- main_test.go | 81 ++++++++++++++++ 5 files changed, 349 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 21986f1..f7b5938 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,20 @@ 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. + `procman` is distributed via Go source code, not via a Homebrew package. diff --git a/go.mod b/go.mod index b684366..8758a15 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.21 +require ( + github.com/bmatcuk/doublestar/v4 v4.10.0 + github.com/creack/pty v1.1.21 + 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 1482e04..735c00e 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.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +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..5114fd1 100644 --- a/main.go +++ b/main.go @@ -16,32 +16,40 @@ 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 type manager struct { output *output procs []*process + watchers []*watcher procWg sync.WaitGroup done chan bool interrupted chan os.Signal @@ -50,9 +58,14 @@ type manager struct { // 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 + restarting bool + done chan struct{} // closed when run() completes } // output manages the output display of processes @@ -62,6 +75,16 @@ type output struct { pipes map[*process]*os.File } +// watcher monitors files and restarts a process on changes +type watcher struct { + fsw *fsnotify.Watcher + proc *process + mgr *manager + workDir string + stopCh chan struct{} + stopOnce sync.Once +} + func main() { procNames, err := parseArgs(os.Args) if err != nil { @@ -88,6 +111,8 @@ func main() { mgr.runProcess(proc) } + mgr.setupWatchers() + go mgr.waitForExit() mgr.procWg.Wait() } @@ -129,7 +154,7 @@ func parseProcfile(r io.Reader) ([]procDef, error) { } params := procfileRe.FindStringSubmatch(line) - if len(params) != 3 { + if len(params) < 3 { continue } @@ -138,7 +163,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 +186,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) } @@ -186,7 +223,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 <- true + } + }() proc.run() }() } @@ -198,6 +242,10 @@ func (mgr *manager) waitForExit() { case <-mgr.interrupted: } + for _, w := range mgr.watchers { + w.stop() + } + for _, proc := range mgr.procs { proc.interrupt() } @@ -226,6 +274,7 @@ func (proc *process) run() { proc.output.pipeOutput(proc) defer proc.output.closePipe(proc) + defer close(proc.done) if err := proc.Cmd.Wait(); err != nil { proc.output.writeErr(proc, err) @@ -318,3 +367,177 @@ 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 file watchers for processes with watch patterns. +func (mgr *manager) setupWatchers() { + for _, proc := range mgr.procs { + if len(proc.watchPatterns) == 0 { + continue + } + w, err := newWatcher(proc, mgr) + if err != nil { + proc.output.writeErr(proc, fmt.Errorf("watch setup: %v", err)) + continue + } + mgr.watchers = append(mgr.watchers, w) + go w.run() + } +} + +// newWatcher creates a watcher for the given process. +func newWatcher(proc *process, mgr *manager) (*watcher, error) { + fsw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + + wd, _ := os.Getwd() + w := &watcher{ + fsw: fsw, + proc: proc, + mgr: mgr, + workDir: wd, + stopCh: make(chan struct{}), + } + + // Find and watch directories containing files matching patterns + dirs := make(map[string]bool) + 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 _, match := range matches { + dir := filepath.Dir(match) + dirs[dir] = true + } + // Also watch parent directories for new files + if idx := strings.Index(pattern, "*"); idx > 0 { + base := filepath.Dir(pattern[:idx]) + 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 + }) + } + } + + watchedCount := 0 + for dir := range dirs { + if err := fsw.Add(dir); err != nil { + proc.output.writeErr(proc, fmt.Errorf("watch %s: %v", dir, err)) + } else { + watchedCount++ + } + } + if watchedCount == 0 && len(dirs) > 0 { + fsw.Close() + return nil, fmt.Errorf("failed to watch any directories") + } + + return w, nil +} + +// run starts the watch loop. +func (w *watcher) run() { + var timer *time.Timer + var timerC <-chan time.Time + + for { + select { + case <-w.stopCh: + if timer != nil { + timer.Stop() + } + w.fsw.Close() + return + case event, ok := <-w.fsw.Events: + if !ok { + return + } + if !w.matchesPattern(event.Name) { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { + continue + } + // Debounce + if timer != nil { + timer.Stop() + } + timer = time.NewTimer(debounce) + timerC = timer.C + case <-timerC: + w.restart() + timerC = nil + case err, ok := <-w.fsw.Errors: + if !ok { + return + } + w.proc.output.writeErr(w.proc, err) + } + } +} + +// matchesPattern checks if a file path matches any watch pattern. +func (w *watcher) matchesPattern(path string) bool { + // fsnotify returns absolute paths; convert to relative for pattern matching + if w.workDir != "" { + if rel, err := filepath.Rel(w.workDir, path); err == nil { + path = rel + } + } + for _, pattern := range w.proc.watchPatterns { + matched, err := doublestar.Match(pattern, path) + if err != nil { + w.proc.output.writeErr(w.proc, fmt.Errorf("pattern %q: %v", pattern, err)) + continue + } + if matched { + return true + } + } + return false +} + +// restart stops and restarts the watched process. +func (w *watcher) restart() { + w.proc.mu.Lock() + if w.proc.restarting || !w.proc.running() { + w.proc.mu.Unlock() + return + } + w.proc.restarting = true + done := w.proc.done + w.proc.mu.Unlock() + + w.proc.output.writeLine(w.proc, []byte("\033[0;33mrestarting...\033[0m")) + w.proc.interrupt() + + // Wait for process to exit (with timeout) + select { + case <-done: + case <-time.After(timeout): + w.proc.kill() + <-done // still wait for run() to complete + } + + // Create new command and restart + w.proc.mu.Lock() + w.proc.Cmd = exec.Command("/bin/sh", "-c", w.proc.cmdStr) + w.proc.done = make(chan struct{}) + w.proc.restarting = false + w.proc.mu.Unlock() + + w.mgr.runProcess(w.proc) +} + +// stop signals the watcher to stop. +func (w *watcher) stop() { + w.stopOnce.Do(func() { close(w.stopCh) }) +} diff --git a/main_test.go b/main_test.go index 29e1ec7..81e7070 100644 --- a/main_test.go +++ b/main_test.go @@ -104,6 +104,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 { @@ -246,6 +263,70 @@ func TestWriteErr(t *testing.T) { } } +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) { + w := &watcher{ + proc: &process{watchPatterns: tt.patterns, output: &output{}}, + workDir: "", // empty means path is already relative + } + if got := w.matchesPattern(tt.path); got != tt.want { + t.Errorf("matchesPattern(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + // TestProcmanIntegration tests the full procman workflow. func TestProcmanIntegration(t *testing.T) { content := "echo: echo 'hello'\nsleep: sleep 10" From eee7852d95b62a0cbb82578df66182c05b3be146 Mon Sep 17 00:00:00 2001 From: Dan Croak Date: Wed, 4 Feb 2026 12:01:43 -0500 Subject: [PATCH 2/3] watch: fix race condition and shutdown hang - Fix data race in running(): use mutex-protected started/stopped flags instead of reading exec.Cmd internals (ProcessState) without sync - Fix hanging goroutines on shutdown: use time.AfterFunc instead of goroutine waiting on timer channel that could block forever if drained - Consolidate to single shared fsnotify watcher at manager level - Improve test cleanup to avoid orphaned processes Co-Authored-By: Warp --- README.md | 8 +- main.go | 300 ++++++++++++++++++++++++++++----------------------- main_test.go | 84 +++++++++++++-- 3 files changed, 247 insertions(+), 145 deletions(-) diff --git a/README.md b/README.md index f7b5938..bd86f64 100644 --- a/README.md +++ b/README.md @@ -73,12 +73,16 @@ 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/main.go b/main.go index 5114fd1..f3a38e0 100644 --- a/main.go +++ b/main.go @@ -49,10 +49,17 @@ type procDef struct { type manager struct { output *output procs []*process - watchers []*watcher 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 @@ -64,6 +71,8 @@ type process struct { output *output watchPatterns []string mu sync.Mutex + started bool + stopped bool restarting bool done chan struct{} // closed when run() completes } @@ -75,15 +84,6 @@ type output struct { pipes map[*process]*os.File } -// watcher monitors files and restarts a process on changes -type watcher struct { - fsw *fsnotify.Watcher - proc *process - mgr *manager - workDir string - stopCh chan struct{} - stopOnce sync.Once -} func main() { procNames, err := parseArgs(os.Args) @@ -213,7 +213,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) } @@ -228,7 +228,7 @@ func (mgr *manager) runProcess(proc *process) { restarting := proc.restarting proc.mu.Unlock() if !restarting { - mgr.done <- true + mgr.done <- struct{}{} } }() proc.run() @@ -242,9 +242,7 @@ func (mgr *manager) waitForExit() { case <-mgr.interrupted: } - for _, w := range mgr.watchers { - w.stop() - } + mgr.stopWatcher() for _, proc := range mgr.procs { proc.interrupt() @@ -262,7 +260,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. @@ -274,7 +274,12 @@ func (proc *process) run() { proc.output.pipeOutput(proc) defer proc.output.closePipe(proc) - defer close(proc.done) + 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) @@ -320,6 +325,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() @@ -339,6 +348,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 { @@ -368,54 +378,44 @@ func (out *output) writeErr(proc *process, err error) { out.writeLine(proc, []byte(fmt.Sprintf("\033[0;31m%v\033[0m", err))) } -// setupWatchers creates file watchers for processes with watch patterns. +// setupWatchers creates a single watcher for all processes with watch patterns. func (mgr *manager) setupWatchers() { - for _, proc := range mgr.procs { - if len(proc.watchPatterns) == 0 { - continue - } - w, err := newWatcher(proc, mgr) - if err != nil { - proc.output.writeErr(proc, fmt.Errorf("watch setup: %v", err)) - continue + has := false + for _, p := range mgr.procs { + if len(p.watchPatterns) > 0 { + has = true + break } - mgr.watchers = append(mgr.watchers, w) - go w.run() } -} + if !has { + return + } -// newWatcher creates a watcher for the given process. -func newWatcher(proc *process, mgr *manager) (*watcher, error) { fsw, err := fsnotify.NewWatcher() if err != nil { - return nil, err - } - - wd, _ := os.Getwd() - w := &watcher{ - fsw: fsw, - proc: proc, - mgr: mgr, - workDir: wd, - stopCh: make(chan struct{}), + 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) - // Find and watch directories containing files matching patterns dirs := make(map[string]bool) - 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 _, match := range matches { - dir := filepath.Dir(match) - dirs[dir] = true - } - // Also watch parent directories for new files - if idx := strings.Index(pattern, "*"); idx > 0 { - base := filepath.Dir(pattern[:idx]) - filepath.WalkDir(base, func(path string, d os.DirEntry, err error) error { + 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 } @@ -426,118 +426,154 @@ func newWatcher(proc *process, mgr *manager) (*watcher, error) { }) } } - - watchedCount := 0 - for dir := range dirs { - if err := fsw.Add(dir); err != nil { - proc.output.writeErr(proc, fmt.Errorf("watch %s: %v", dir, err)) - } else { - watchedCount++ + 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 watchedCount == 0 && len(dirs) > 0 { - fsw.Close() - return nil, fmt.Errorf("failed to watch any directories") + if count == 0 && len(dirs) > 0 { + mgr.fsw.Close() + mgr.fsw = nil + return } + go mgr.watchLoop() +} - return w, nil +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]) } -// run starts the watch loop. -func (w *watcher) run() { - var timer *time.Timer - var timerC <-chan time.Time +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 <-w.stopCh: - if timer != nil { - timer.Stop() + case <-mgr.watchStopCh: + mgr.stopAllTimers() + if mgr.fsw != nil { + mgr.fsw.Close() } - w.fsw.Close() return - case event, ok := <-w.fsw.Events: + case event, ok := <-mgr.fsw.Events: if !ok { return } - if !w.matchesPattern(event.Name) { - continue - } if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { continue } - // Debounce - if timer != nil { - timer.Stop() + for _, proc := range mgr.procs { + if len(proc.watchPatterns) == 0 { + continue + } + if mgr.matchPatterns(proc.watchPatterns, event.Name) { + mgr.scheduleRestart(proc) + } } - timer = time.NewTimer(debounce) - timerC = timer.C - case <-timerC: - w.restart() - timerC = nil - case err, ok := <-w.fsw.Errors: + case err, ok := <-mgr.fsw.Errors: if !ok { return } - w.proc.output.writeErr(w.proc, err) + if len(mgr.procs) > 0 { + mgr.procs[0].output.writeErr(mgr.procs[0], err) + } } } } -// matchesPattern checks if a file path matches any watch pattern. -func (w *watcher) matchesPattern(path string) bool { - // fsnotify returns absolute paths; convert to relative for pattern matching - if w.workDir != "" { - if rel, err := filepath.Rel(w.workDir, path); err == nil { - path = rel - } +func (mgr *manager) scheduleRestart(proc *process) { + mgr.watchMu.Lock() + defer mgr.watchMu.Unlock() + + if t := mgr.watchTimers[proc]; t != nil { + t.Stop() } - for _, pattern := range w.proc.watchPatterns { - matched, err := doublestar.Match(pattern, path) - if err != nil { - w.proc.output.writeErr(w.proc, fmt.Errorf("pattern %q: %v", pattern, err)) - continue - } - if matched { - return true - } + 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) } - return false } -// restart stops and restarts the watched process. -func (w *watcher) restart() { - w.proc.mu.Lock() - if w.proc.restarting || !w.proc.running() { - w.proc.mu.Unlock() +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 } - w.proc.restarting = true - done := w.proc.done - w.proc.mu.Unlock() + p.restarting = true + done := p.done + p.mu.Unlock() - w.proc.output.writeLine(w.proc, []byte("\033[0;33mrestarting...\033[0m")) - w.proc.interrupt() + p.output.writeLine(p, []byte("\033[0;33mrestarting...\033[0m")) + p.interrupt() - // Wait for process to exit (with timeout) select { case <-done: case <-time.After(timeout): - w.proc.kill() - <-done // still wait for run() to complete + p.kill() + <-done } - // Create new command and restart - w.proc.mu.Lock() - w.proc.Cmd = exec.Command("/bin/sh", "-c", w.proc.cmdStr) - w.proc.done = make(chan struct{}) - w.proc.restarting = false - w.proc.mu.Unlock() - - w.mgr.runProcess(w.proc) -} + 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() -// stop signals the watcher to stop. -func (w *watcher) stop() { - w.stopOnce.Do(func() { close(w.stopCh) }) + mgr.runProcess(p) } diff --git a/main_test.go b/main_test.go index 81e7070..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" @@ -188,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) { @@ -316,18 +325,13 @@ func TestMatchesPattern(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - w := &watcher{ - proc: &process{watchPatterns: tt.patterns, output: &output{}}, - workDir: "", // empty means path is already relative - } - if got := w.matchesPattern(tt.path); got != tt.want { + 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) } }) } } - -// TestProcmanIntegration tests the full procman workflow. func TestProcmanIntegration(t *testing.T) { content := "echo: echo 'hello'\nsleep: sleep 10" if err := os.WriteFile("Procfile.dev", []byte(content), 0644); err != nil { @@ -350,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") + } +} From cfb4bdd40e5c7033f51f86388b8dfe8803dc7ea4 Mon Sep 17 00:00:00 2001 From: Dan Croak Date: Wed, 4 Feb 2026 12:04:34 -0500 Subject: [PATCH 3/3] 1.1.24 --- go.mod | 2 +- go.sum | 4 ++-- main.go | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 8758a15..33afd06 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25 require ( github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/creack/pty v1.1.21 + github.com/creack/pty v1.1.24 github.com/fsnotify/fsnotify v1.9.0 ) diff --git a/go.sum b/go.sum index 735c00e..32f55e7 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ 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.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0= -github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +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= diff --git a/main.go b/main.go index f3a38e0..06d16fc 100644 --- a/main.go +++ b/main.go @@ -84,7 +84,6 @@ type output struct { pipes map[*process]*os.File } - func main() { procNames, err := parseArgs(os.Args) if err != nil {