diff --git a/README.md b/README.md index 0a490f3..67fb75f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ type Config struct { ExecList []string `toml:"exec_list" yaml:"exec_list"` // Simpler form, see [Execute Lifecycle] LogLevel string `toml:"log_level" yaml:"log_level"` Debounce int `toml:"debounce" yaml:"debounce"` + EnablePause bool `toml:"enable_pause" yaml:"enable_pause"` // Use Ctrl+Z to toggle pause/resume instead of suspending (Unix only) Callback func(*EventCallback) EventHandle Slog *slog.Logger } @@ -60,14 +61,14 @@ type Config struct { type Ignore struct { Dir []string `toml:"dir" yaml:"dir"` // Directories to ignore, e.g. node_modules File []string `toml:"file" yaml:"file"` // Files to ignore - WatchedExten []string `toml:"watched_extension" yaml:"watched_extension"` // Extensions to watch; anything else is ignored + WatchedExten []string `toml:"watched_extension" yaml:"watched_extension"` // Extensions to watch; anything else is ignored. Empty watches all files. IgnoreGit bool `toml:"git" yaml:"git"` // When true, .gitignore entries in the root are also ignored } type Execute struct { Cmd string `toml:"cmd" yaml:"cmd"` // Command to run ChangeDir string `toml:"dir" yaml:"dir"` // Directory to run in, relative to root_path - DelayNext int `toml:"delay_next" yaml:"delay_next"` // Delay in milliseconds before running + DelayNext int `toml:"delay_next" yaml:"delay_next"` // Pause in milliseconds after this step, before the next one starts Type ExecuteType `toml:"type" yaml:"type"` // background | once | blocking | primary } ``` diff --git a/cmd/refresh/main.go b/cmd/refresh/main.go index 6d6e5c7..e3481e1 100644 --- a/cmd/refresh/main.go +++ b/cmd/refresh/main.go @@ -21,6 +21,7 @@ type cliFlags struct { debounce int version bool gitIgnore bool + trapSuspend bool ignoreDir string ignoreFile string ignoreExt string @@ -40,6 +41,7 @@ func parseFlags(args []string) (cliFlags, error) { fs.IntVar(&f.debounce, "d", 1000, "Debounce time in milliseconds") fs.BoolVar(&f.version, "v", false, "Print version") fs.BoolVar(&f.gitIgnore, "git", false, "Read .gitignore in the root") + fs.BoolVar(&f.trapSuspend, "pause", false, "Use Ctrl+Z to toggle pause/resume instead of suspending") if err := fs.Parse(args); err != nil { return f, err } @@ -64,10 +66,11 @@ func splitList(csv string) []string { // toConfig maps the flags to an engine.Config (used when no config file is given). func (f cliFlags) toConfig() refresh.Config { return refresh.Config{ - RootPath: f.rootPath, - ExecList: splitList(f.execCommand), - LogLevel: f.logLevel, - Debounce: f.debounce, + RootPath: f.rootPath, + ExecList: splitList(f.execCommand), + LogLevel: f.logLevel, + Debounce: f.debounce, + EnablePause: f.trapSuspend, Ignore: refresh.Ignore{ File: splitList(f.ignoreFile), Dir: splitList(f.ignoreDir), diff --git a/engine/config.go b/engine/config.go index dedbc03..28431a2 100644 --- a/engine/config.go +++ b/engine/config.go @@ -21,8 +21,13 @@ type Config struct { ExecList []string `toml:"exec_list" yaml:"exec_list"` LogLevel string `toml:"log_level" yaml:"log_level"` Debounce int `toml:"debounce" yaml:"debounce"` - Callback func(*EventCallback) EventHandle - Slog *slog.Logger + // EnablePause, when true, repurposes the terminal suspend key (Ctrl+Z / + // SIGTSTP) as a pause/resume toggle: the first press pauses reloads, the next + // resumes. This overrides the shell's normal "suspend to background" behavior, + // so it is opt-in. No-op on platforms without SIGTSTP (Windows). + EnablePause bool `toml:"enable_pause" yaml:"enable_pause"` + Callback func(*EventCallback) EventHandle + Slog *slog.Logger } func DefaultEngineConfig() Config { @@ -54,6 +59,13 @@ func (c *Config) WithDebounce(value int) *Config { return c } +// WithEnablePause opts into using the terminal suspend key (Ctrl+Z / SIGTSTP) +// as a pause/resume toggle instead of suspending the process. +func (c *Config) WithEnablePause(truthy bool) *Config { + c.EnablePause = truthy + return c +} + func (c *Config) WithIgnore(ignore Ignore) *Config { c.Ignore = ignore return c @@ -129,6 +141,11 @@ func (engine *Engine) verifyConfig() error { if engine.Config.RootPath == "" { return errors.New("root path is required") } + // The background block always runs as a background process; a type set on it + // is dropped, so warn rather than silently ignore it. + if t := engine.Config.BackgroundStruct.Type; t != "" && t != process.Background { + slog.Warn("background.type is ignored; the background command always runs as a background process", "ignored_type", t) + } engine.normalizeExecutes() if err := engine.verifyExecute(); err != nil { return err @@ -240,9 +257,9 @@ func (e *Engine) generateProcess() { // A configured background command is started once at startup, survives // reloads, and is killed on shutdown — regardless of any Type set on it. if bg := e.Config.BackgroundStruct; bg.Cmd != "" { - _ = e.ProcessManager.AddProcess(bg.Cmd, string(process.Background), bg.ChangeDir) + _ = e.ProcessManager.AddProcessWithDelay(bg.Cmd, string(process.Background), bg.ChangeDir, bg.DelayNext) } for _, ex := range e.Config.ExecStruct { - _ = e.ProcessManager.AddProcess(ex.Cmd, string(ex.Type), ex.ChangeDir) + _ = e.ProcessManager.AddProcessWithDelay(ex.Cmd, string(ex.Type), ex.ChangeDir, ex.DelayNext) } } diff --git a/engine/config_test.go b/engine/config_test.go index 8daecda..540271b 100644 --- a/engine/config_test.go +++ b/engine/config_test.go @@ -94,6 +94,22 @@ func TestBackgroundStructBecomesProcess(t *testing.T) { } } +func TestBackgroundTypeIsIgnored(t *testing.T) { + // A type set on the background block is dropped: the background command always + // registers as a background process regardless of what Type was configured. + eng, err := NewEngineFromConfig(Config{ + RootPath: ".", + BackgroundStruct: process.Execute{Cmd: "echo bg", Type: process.Primary}, + ExecStruct: []process.Execute{{Cmd: "./app", Type: process.Primary}}, + }) + if err != nil { + t.Fatal(err) + } + if got := eng.ProcessManager.Processes[0].Type; got != process.Background { + t.Errorf("background process type = %q, want %q (type on the background block must be ignored)", got, process.Background) + } +} + func TestNormalizeExecutesPrefersStruct(t *testing.T) { e := &Engine{Config: Config{ RootPath: ".", diff --git a/engine/engine.go b/engine/engine.go index 5465000..4276bd1 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -8,7 +8,6 @@ import ( "os" "os/signal" "syscall" - "time" "github.com/atterpac/refresh/process" ) @@ -69,9 +68,6 @@ func (engine *Engine) Start() error { if engine.Config.Ignore.IgnoreGit { engine.Config.Ignore.gitPatterns = readGitIgnore(engine.Config.RootPath) } - if delay := engine.Config.BackgroundStruct.DelayNext; delay > 0 { - time.Sleep(time.Duration(delay) * time.Millisecond) - } ctx, cancel := context.WithCancel(context.Background()) engine.ctx = ctx @@ -95,15 +91,45 @@ func (engine *Engine) Start() error { return err } + // Optional pause/resume toggle, driven by the suspend key (Ctrl+Z). The + // buffered, non-blocking send keeps the toggle from blocking the signal + // goroutine and coalesces rapid presses. + toggle := make(chan struct{}, 1) + if engine.Config.EnablePause { + engine.trapControlSignals(toggle) + } + // Supervisor loop: the only goroutine that drives process lifecycle, so the - // process manager needs no locking around its runtime state. + // process manager needs no locking around its runtime state. paused and + // pending live here for the same reason. + paused := false + pending := false // a reload arrived while paused + for { select { case <-ctx.Done(): engine.ProcessManager.Shutdown() slog.Info("refresh stopped") return nil + case <-toggle: + paused = !paused + if paused { + slog.Warn("refresh paused — file changes ignored until resumed") + continue + } + slog.Warn("refresh resumed") + if pending { + pending = false + slog.Info("applying change made while paused, reloading") + if err := engine.ProcessManager.Reload(ctx); err != nil { + slog.Error("reload failed", "err", err) + } + } case <-reload: + if paused { + pending = true + continue + } slog.Info("change detected, reloading") if err := engine.ProcessManager.Reload(ctx); err != nil { slog.Error("reload failed", "err", err) diff --git a/engine/ignore.go b/engine/ignore.go index e4e7b39..d0121dc 100644 --- a/engine/ignore.go +++ b/engine/ignore.go @@ -32,6 +32,12 @@ func (i *Ignore) shouldIgnore(path string) bool { } func (i *Ignore) isWatchedExtension(path string) bool { + // No configured filter means watch everything; this is the default config + // and the bare-CLI case, so it must reload rather than ignore every change. + if len(i.WatchedExten) == 0 { + return true + } + ext := filepath.Ext(path) if ext == "" { return false diff --git a/engine/ignore_test.go b/engine/ignore_test.go index 48d61f3..4c3aa57 100644 --- a/engine/ignore_test.go +++ b/engine/ignore_test.go @@ -76,6 +76,18 @@ func Test_isWatchedExtension(t *testing.T) { watchedExten: []string{"*.go", "*.js"}, wantIsWatched: false, }, + { + name: "empty filter watches all extensions (default config)", + path: "/some/path/file.go", + watchedExten: nil, + wantIsWatched: true, + }, + { + name: "empty filter watches extensionless files too", + path: "/some/path/Makefile", + watchedExten: nil, + wantIsWatched: true, + }, } for _, tt := range tests { diff --git a/engine/signals_unix.go b/engine/signals_unix.go new file mode 100644 index 0000000..2795d0e --- /dev/null +++ b/engine/signals_unix.go @@ -0,0 +1,27 @@ +//go:build linux || darwin + +package engine + +import ( + "os" + "os/signal" + "syscall" +) + +// trapControlSignals repurposes the terminal suspend key (Ctrl+Z / SIGTSTP) as a +// pause/resume toggle. Trapping SIGTSTP overrides the kernel's default suspend +// disposition, so the process is never actually stopped; each delivery instead +// pokes toggle, which the supervisor loop interprets as flip-paused. The send is +// non-blocking so a press while a toggle is already queued is coalesced. +func (engine *Engine) trapControlSignals(toggle chan<- struct{}) { + ch := make(chan os.Signal, 1) + signal.Notify(ch, syscall.SIGTSTP) + go func() { + for range ch { + select { + case toggle <- struct{}{}: + default: + } + } + }() +} diff --git a/engine/signals_windows.go b/engine/signals_windows.go new file mode 100644 index 0000000..912f04b --- /dev/null +++ b/engine/signals_windows.go @@ -0,0 +1,8 @@ +//go:build windows + +package engine + +// trapControlSignals is a no-op on Windows: there is no SIGTSTP/Ctrl+Z suspend +// signal to repurpose as a pause/resume toggle, so enabling Config.TrapSuspend +// has no effect on this platform. +func (engine *Engine) trapControlSignals(chan<- struct{}) {} diff --git a/engine/watch_test.go b/engine/watch_test.go index 38f1343..46081d0 100644 --- a/engine/watch_test.go +++ b/engine/watch_test.go @@ -83,6 +83,40 @@ func TestWatcherDetectsNestedSubdirectoryChanges(t *testing.T) { } } +// TestWatcherDefaultConfigReloadsAnyFile guards the default/empty-filter path: +// with no WatchedExten configured (DefaultEngineConfig and the bare CLI), every +// change must still trigger a reload. A regression here previously made the +// out-of-the-box config silently watch nothing. +func TestWatcherDefaultConfigReloadsAnyFile(t *testing.T) { + root := t.TempDir() + e := &Engine{Config: Config{ + RootPath: root, + Debounce: 100, + Ignore: Ignore{}, // zero value: no extension filter + }} + e.ProcessManager = process.NewProcessManager() + if err := e.ProcessManager.SetRootDirectory(root); err != nil { + t.Fatal(err) + } + + reload := make(chan struct{}, 16) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := e.startWatcher(ctx, reload); err != nil { + t.Fatalf("startWatcher: %v", err) + } + + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + time.Sleep(300 * time.Millisecond) + cancel() + + if got := len(reload); got != 1 { + t.Errorf("default config produced %d reloads for a .go edit, want 1", got) + } +} + func TestWatcherIgnoresUnwatchedExtensions(t *testing.T) { root := t.TempDir() e := newWatchTestEngine(t, root, 100) diff --git a/examples/basic/config.yaml b/examples/basic/config.yaml index a4e9e08..0657b92 100644 --- a/examples/basic/config.yaml +++ b/examples/basic/config.yaml @@ -5,17 +5,19 @@ config: root_path: "." log_level: "debug" debounce: 500 + # enable_pause: true # Unix only: Ctrl+Z toggles pause/resume instead of suspending ignore: dir: [".git", "node_modules", "vendor", "bin"] file: [".gitignore", ".DS_Store"] - watched_extension: ["*.go"] + watched_extension: ["*.go"] # omit to watch every file git: true # Executes run in order. type is one of: background | once | blocking | primary executes: - cmd: "go build -o ./bin/app ./app" type: "blocking" + # delay_next: 500 # pause in ms after this step, before the next starts - cmd: "./bin/app" type: "primary" diff --git a/process/execute.go b/process/execute.go index 5236c34..5279d3f 100644 --- a/process/execute.go +++ b/process/execute.go @@ -9,7 +9,7 @@ import ( type Execute struct { Cmd string `toml:"cmd" yaml:"cmd"` // Execute command ChangeDir string `toml:"dir" yaml:"dir"` // If directory needs to be changed to call this command relative to the root path - DelayNext int `toml:"delay_next" yaml:"delay_next"` // Delay in milliseconds before running command + DelayNext int `toml:"delay_next" yaml:"delay_next"` // Pause in ms held after this step completes, before the next process starts // Type can have one of a few types to define how it reacts to a file change // background -- runs once at startup and is killed when refresh is canceled // once -- runs once at refresh startup but is blocking diff --git a/process/process.go b/process/process.go index 103a46c..346a052 100644 --- a/process/process.go +++ b/process/process.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "time" ) // Process is a single configured command plus the runtime handles for its @@ -15,6 +16,9 @@ type Process struct { Exec string Type ExecuteType Dir string + // Delay is the pause in milliseconds inserted after this process's step + // completes, before the next configured process starts. Zero means no pause. + Delay int cmd *exec.Cmd cancel context.CancelFunc @@ -38,11 +42,18 @@ func NewProcessManager() *ProcessManager { } func (pm *ProcessManager) AddProcess(exec, typing, dir string) error { + return pm.AddProcessWithDelay(exec, typing, dir, 0) +} + +// AddProcessWithDelay is AddProcess plus delay, the pause in milliseconds held +// after this process's step before the next one starts (the delay_next config +// field). +func (pm *ProcessManager) AddProcessWithDelay(exec, typing, dir string, delay int) error { execType, err := stringToExecuteType(typing) if err != nil { return err } - pm.Processes = append(pm.Processes, &Process{Exec: exec, Type: execType, Dir: dir}) + pm.Processes = append(pm.Processes, &Process{Exec: exec, Type: execType, Dir: dir, Delay: delay}) return nil } @@ -143,11 +154,34 @@ func (pm *ProcessManager) runCycle(ctx context.Context, firstRun bool) error { return err } } + // Reached only when the step above actually ran (skipped/aborted steps + // continue/return before here), so the delay sits strictly between this + // process and the next. + if !pm.delayNext(ctx, p) { + return nil // context cancelled mid-delay — abort the cycle quietly + } } pm.started = true return nil } +// delayNext holds for the process's configured delay_next before the cycle moves +// on, letting one step settle (e.g. a service binding a port) before the next +// starts. The wait is context-aware; it returns false if the context is +// cancelled during the pause so the caller can stop the cycle. +func (pm *ProcessManager) delayNext(ctx context.Context, p *Process) bool { + if p.Delay <= 0 { + return true + } + slog.Debug("delaying before next process", "exec", p.Exec, "ms", p.Delay) + select { + case <-ctx.Done(): + return false + case <-time.After(time.Duration(p.Delay) * time.Millisecond): + return true + } +} + // startAsync launches a long-lived process (background or primary) in its own // process group and tracks it so it can be terminated on the next cycle or // shutdown. The command is started in a fresh process group so the whole tree