From dc90b9878a3bb9c5a0c5f4978d722af749942d1a Mon Sep 17 00:00:00 2001 From: atterpac Date: Sat, 30 May 2026 15:00:03 -0600 Subject: [PATCH 1/5] empty watched_extension watches all files An empty WatchedExten made shouldIgnore reject every path, so the default config (and bare CLI) silently watched nothing and never reloaded. Treat an empty filter as watch-all. Add the previously-missing zero-value cases: a nil filter in the isWatchedExtension table, and an end-to-end watcher test driven by a zero-value Ignore. --- engine/ignore.go | 6 ++++++ engine/ignore_test.go | 12 ++++++++++++ engine/watch_test.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) 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/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) From 4044d6a36932b60b5ba10232490ae85219d7598b Mon Sep 17 00:00:00 2001 From: atterpac Date: Sat, 30 May 2026 15:03:34 -0600 Subject: [PATCH 2/5] implement per-execute delay_next as a trailing pause delay_next was parsed onto Execute but dropped on the way to Process, so it did nothing for configured executes. Wire it through AddProcessWithDelay and pause for the configured duration after a step completes, before the next one starts (context-aware, so shutdown mid-delay aborts cleanly). Also remove the one-off pre-start time.Sleep on BackgroundStruct.DelayNext, which double-counted against the new trailing semantics. --- engine/config.go | 4 ++-- engine/engine.go | 4 ---- process/execute.go | 2 +- process/process.go | 36 +++++++++++++++++++++++++++++++++++- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/engine/config.go b/engine/config.go index dedbc03..535abd4 100644 --- a/engine/config.go +++ b/engine/config.go @@ -240,9 +240,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/engine.go b/engine/engine.go index 5465000..09aa802 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 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 From b68e6d6a88a7f3a0c128b926a80fed9e11bfd743 Mon Sep 17 00:00:00 2001 From: atterpac Date: Sat, 30 May 2026 15:04:39 -0600 Subject: [PATCH 3/5] add opt-in pause/resume to the watcher via Ctrl+Z When enable_pause is set, trap the terminal suspend signal (SIGTSTP) and use it as a pause/resume toggle instead of suspending: the supervisor loop gates reloads while paused and applies a single catch-up reload on resume if a change arrived in the meantime. Off by default so normal Ctrl+Z behavior is preserved; no-op on platforms without SIGTSTP (Windows). Exposed via Config.EnablePause, WithEnablePause, and the -pause CLI flag. --- cmd/refresh/main.go | 11 +++++++---- engine/config.go | 16 ++++++++++++++-- engine/engine.go | 32 +++++++++++++++++++++++++++++++- engine/signals_unix.go | 27 +++++++++++++++++++++++++++ engine/signals_windows.go | 8 ++++++++ 5 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 engine/signals_unix.go create mode 100644 engine/signals_windows.go 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 535abd4..0bf3323 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 diff --git a/engine/engine.go b/engine/engine.go index 09aa802..4276bd1 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -91,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/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{}) {} From 516bc22d9edcb35cfdf78229596cf8a2e238ae82 Mon Sep 17 00:00:00 2001 From: atterpac Date: Sat, 30 May 2026 15:04:48 -0600 Subject: [PATCH 4/5] warn when background.type is set A type set on the background block is always dropped (the background command runs as a background process regardless), so surface it with a warning instead of silently ignoring it. Lock the drop behavior with a test. --- engine/config.go | 5 +++++ engine/config_test.go | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/engine/config.go b/engine/config.go index 0bf3323..28431a2 100644 --- a/engine/config.go +++ b/engine/config.go @@ -141,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 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: ".", From 474b9a22d9cb37a0a0fecaf102d672c02126a1fa Mon Sep 17 00:00:00 2001 From: atterpac Date: Sat, 30 May 2026 15:08:12 -0600 Subject: [PATCH 5/5] document enable_pause, delay_next semantics, empty watched_extension Add EnablePause to the README Config struct, correct the delay_next comment to the trailing-pause meaning, note that an empty watched_extension watches all, and surface all three in the basic example config. --- README.md | 5 +++-- examples/basic/config.yaml | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) 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/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"