diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d17c41d..ca6b978 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,19 +8,18 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: '>=1.21.0' - id: go + go-version: '1.25.x' - - name: Tidy - run: go mod tidy + - name: Vet + run: go vet ./... - name: Test - run: go test -v ./engine + run: go test -race ./... build: runs-on: ${{ matrix.os }} @@ -31,21 +30,18 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: '>=1.21.0' - - - name: Dependencies - run: go mod tidy + go-version: '1.25.x' - name: Build application run: go build ./cmd/refresh/ - name: Upload artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: - name: refresh - path: ./refresh + name: refresh-${{ matrix.os }} + path: ./refresh* diff --git a/.gitignore b/.gitignore index cde0123..4eedd01 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ - dist/ + +# Build artifacts — never commit compiled binaries +/app +bin/ +*.exe + +# Example runtime output +examples/kitchen-sink/artifacts/ +examples/kitchen-sink/sub/marker.txt diff --git a/README.md b/README.md index 9b16086..0a490f3 100644 --- a/README.md +++ b/README.md @@ -39,37 +39,36 @@ These declarations let refresh know when you would like to kill the stale proces Whatever command after REFRESH is considered your "main" subprocess and the one that is tracked inside of refresh ## Embedding into your dev project -There can be some uses where you might want to start a watcher internally or for a tool for development refresh provides a function `NewEngineFromOptions` which takes an `engine.Config` and allows for the `engine.Start()` function +There can be some uses where you might want to start a watcher internally or for a tool for development refresh provides `NewEngineFromConfig`, which takes an `engine.Config` and returns an engine you can `Start()`. Using refresh as a library also opens the ability to add a [Callback](https://github.com/atterpac/refresh#reload-callback) function that is called on every FS notification ### Structs ```go type Config struct { - RootPath string `toml:"root_path"` - BackgroundExec string `toml:"background_exec"` // Execute that stays running and is unaffected by any reloads npm run dev for example - BackgroundCheck bool `toml:"background_check"` - Ignore Ignore `toml:"ignore"` - ExecList []string `toml:"exec_list"` // See [Execute Lifecycle](https://github.com/atterpac/refresh#execute-lifecycle) - LogLevel string `toml:"log_level"` - Debounce int `toml:"debounce"` - Callback func(*EventCallback) EventHandle - Slog *slog.Logger + RootPath string `toml:"root_path" yaml:"root_path"` + BackgroundStruct process.Execute `toml:"background" yaml:"background"` // Execute that stays running and is unaffected by reloads (e.g. npm run dev) + Ignore Ignore `toml:"ignore" yaml:"ignore"` + ExecStruct []process.Execute `toml:"executes" yaml:"executes"` // Preferred: typed executes, see [Execute Lifecycle] + 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"` + Callback func(*EventCallback) EventHandle + Slog *slog.Logger } type Ignore struct { - Dir []string `toml:"dir"` // Specfic directory to ignore ie; node_modules - File []string `toml:"file"` // Specific file to ignore - WatchExten []string `toml:"extension"` // Extensions to watch NOT ignore, ie; `*.go, *.js` would ignore any file that is not go or javascript - GitIgnore bool `toml:"git_ignore"` // When true will check for a .gitignore in the root directory and add all entries to the ignore + 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 + 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"` // Execute command - ChangeDir string `toml:"dir" yaml:"dir"` // If directory needs to be changed to call this command relative to the root path - IsBlocking bool `toml:"blocking" yaml:"blocking"` // Should the following executes wait for this one to complete - IsPrimary bool `toml:"primary" yaml:"primary"` // Only one primary command can be run at a time - DelayNext int `toml:"delay_next" yaml:"delay_next"` // Delay in milliseconds before running command + 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 + Type ExecuteType `toml:"type" yaml:"type"` // background | once | blocking | primary } ``` @@ -90,45 +89,36 @@ func main () { Dir: []string{".git","*/node_modules", "!api/*"}, // Ignore .git and any node_modules in the directory or anything not within the api directory IgnoreGit: true, // .gitignore sitting in the root directory? set this to true to automatially ignore those files } - // Build execute structs + // Build execute structs. Type is one of: background | once | blocking | primary tidy := engine.Execute{ - Cmd: "go mod tidy", - IsBlocking: true, // Next command should wait for this to finish + Cmd: "go mod tidy", + Type: engine.Blocking, // Next command waits for this to finish } build := engine.Execute{ - Cmd: "go build -o ./bin/myapp", - IsBlocking: true, // Wait to kill (next step) until the new binary is built + Cmd: "go build -o ./bin/myapp", + Type: engine.Blocking, // Block until the new binary is built before restarting } - // Provided KILL_STALE will tell refresh when you would like to remove the stale process to prepare to launch the new one - kill := engine.KILL_STALE - // Primary process usually runs your binary + // Primary process usually runs your binary; it is killed and restarted on each reload. run := engine.Execute{ - ChangeDir: "./bin", // Change directory to call command in - Cmd: "./myapp", - IsBlocking: false, // Should not block because it doesnt finish until Killed by refresh - IsPrimary: true, // This is the main process refersh is rerunning so denoting it as primary + ChangeDir: "./bin", // Directory to run the command in (relative to root_path) + Cmd: "./myapp", + Type: engine.Primary, } - // Create config to pass into refresh.NewEngineFromConfig() + // Create config to pass into engine.NewEngineFromConfig() config := engine.Config{ - RootPath: "./test", - // Below is ran when a reload is triggered before killing the stale version + RootPath: "./test", Ignore: ignore, - Debounce: 1000, // Time in ms to ignore repitive reload triggers usually caused by an OS creating multiple write/rename events for a singular change - LogLevel: "debug", // debug | info | warn | error | mute -> surpresses all logs to the stdOut - Callback: RefreshCallback, // func(*engine.Callback) refresh.EventHandle {} - ExecStruct: []refresh.Execute{tidy, build, kill, run}, - // Alternatively for easier config but less control over executes - // ExecList: []string{"go mod tidy", "go build -o ./myapp", refresh.KILL_EXEC, refresh.REFRESH_EXEC, "./myapp"} - // All calls will be blocking with the exception of the call after REFRESH - // Both KILL_EXEC and REFRESH_EXEC are **REQUIRED** for refresh to function properly - // engine.KILL_EXEC denotes when the stale process should be killed - // engine.REFRESH_EXEC denotes the next execute is "primary" - Slog: nil, // Optionally provide a slog interface - // if nil a default will be provided - // If provided stdout will not be piped through refresh + Debounce: 1000, // Time in ms to coalesce repetitive reload triggers (the last save in a burst wins) + LogLevel: "debug", // debug | info | warn | error | mute + Callback: RefreshCallback, // func(*engine.EventCallback) engine.EventHandle + ExecStruct: []engine.Execute{tidy, build, run}, + // Alternatively, the simpler ExecList form. REFRESH_EXEC marks the command + // after it as the primary process; everything else runs blocking in order. + // ExecList: []string{"go mod tidy", "go build -o ./myapp", engine.REFRESH_EXEC, "./myapp"} + Slog: nil, // Optionally provide your own *slog.Logger; a default is used if nil } - engine, err := refresh.NewEngineFromConfig(config) + engine, err := engine.NewEngineFromConfig(config) if err != nil { //Handle err } @@ -248,12 +238,28 @@ func ExampleCallback(e refresh.EventCallback) refresh.EventHandle { return engine.EventContinue } ``` +### Logging + +Refresh ships with a built-in structured logger. The level is set via the +`log_level` config field (`debug | info | warn | error | mute`) and can also be +controlled at runtime — these are safe to call from any goroutine: + +```go +engine.SetLogLevel("debug") // change verbosity live ("mute" suppresses output) +engine.DisableLogs() // mute without losing the configured level +engine.EnableLogs() // resume at the previous level +engine.SetLogger(myLogger) // supply your own *slog.Logger (still controllable) +``` + +`DisableLogs`/`EnableLogs` toggle a single switch shared by the whole logger, so +re-enabling restores the previously configured level. Subprocess stdout/stderr +is written straight to the terminal and is not affected by these controls. + ### Config File If you would prefer to load from a [config](https://github.com/Atterpac/refresh#config-file) file rather than building the structs you can use ```go engine.NewEngineFromTOML("path/to/toml") -engine.SetLogger(//Input slog.Logger) ``` #### Example Config ```toml @@ -262,11 +268,10 @@ engine.SetLogger(//Input slog.Logger) root_path = "./" # debug | info(default) | warn | error | mute log_level = "info" -# Debounce setting for ignoring reptitive file system notifications +# Debounce setting for coalescing repetitive file system notifications debounce = 1000 # Milliseconds -# Sets what files the watcher should ignore -background_check = true +# Sets what files the watcher should ignore [config.ignore] # Ignore follows normal pattern matching including /**/ # Directories to ignore @@ -308,15 +313,6 @@ cmd="./app" primary=true ``` -### Background Check Callback -There are instances where you want to wait for the "build" steps for something like vite or a server connection that could take a varying amount -of time to reach a ready state. Refresh adds `engine.AttachBackgroundCallback()` which will hault the execute commands until the callback returns -true (or false for error and shutting down). This could be used along side a ping to the vite port for example to ensure it is reached before -running commands that rely on it. This requires 2 things - -- A callback function that is `func() bool` and returns true when ready and false when errored or exited -- Attaching the callback via `engine.AttachBackgroundCallback()` prior to running `engine.Start()` - #### Flags This method is possible but not the most verbose and controlled way to use refresh diff --git a/app b/app deleted file mode 100755 index a3a9805..0000000 Binary files a/app and /dev/null differ diff --git a/cmd/refresh/main.go b/cmd/refresh/main.go index b808840..6d6e5c7 100644 --- a/cmd/refresh/main.go +++ b/cmd/refresh/main.go @@ -5,97 +5,121 @@ import ( "fmt" "log/slog" "os" - "strconv" "strings" refresh "github.com/atterpac/refresh/engine" ) -func main() { - var version string = "0.4.9" - - var rootPath string - var execCommand string - var logLevel string - var configPath string - var debounce string +const version = "0.4.9" - var versFlag bool - var gitIgnore bool +// cliFlags holds the parsed command-line configuration. +type cliFlags struct { + rootPath string + execCommand string + logLevel string + configPath string + debounce int + version bool + gitIgnore bool + ignoreDir string + ignoreFile string + ignoreExt string +} - // Ignore - var ignoreDir string - var ignoreFile string - var ignoreExt string +// parseFlags parses args (without the program name) into a cliFlags. +func parseFlags(args []string) (cliFlags, error) { + var f cliFlags + fs := flag.NewFlagSet("refresh", flag.ContinueOnError) + fs.StringVar(&f.rootPath, "p", "./", "Root path to watch") + fs.StringVar(&f.execCommand, "e", "", "Comma-separated commands to execute on changes") + fs.StringVar(&f.logLevel, "l", "info", "Log level: debug|info|warn|error|mute") + fs.StringVar(&f.configPath, "f", "", "Config file to read (.toml or .yaml)") + fs.StringVar(&f.ignoreDir, "id", "", "Ignore directories (comma-separated)") + fs.StringVar(&f.ignoreFile, "if", "", "Ignore files (comma-separated)") + fs.StringVar(&f.ignoreExt, "ie", "", "Watched extensions (comma-separated)") + 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") + if err := fs.Parse(args); err != nil { + return f, err + } + return f, nil +} - flag.StringVar(&rootPath, "p", "./", "Root path to watch") - flag.StringVar(&execCommand, "e", "", "Command to execute on changes") - flag.StringVar(&logLevel, "l", "info", "Level to set Logs") - flag.StringVar(&configPath, "f", "", "File to read config from") - flag.StringVar(&ignoreDir, "id", "", "Ignore Directory list as comma-separated list") - flag.StringVar(&ignoreFile, "if", "", "Ignore File list as comma-separated list") - flag.StringVar(&ignoreExt, "ie", "", "Watched Extension list as comma-separated list") - flag.StringVar(&debounce, "d", "1000", "Debounce time in milliseconds") - flag.BoolVar(&versFlag, "v", false, "Print version") - flag.BoolVar(&gitIgnore, "git", false, "Read from .gitignore") - flag.Parse() +// splitList splits a comma-separated flag value, trimming whitespace and +// dropping empty entries (so an unset flag yields nil, not [""]). +func splitList(csv string) []string { + if strings.TrimSpace(csv) == "" { + return nil + } + var out []string + for p := range strings.SplitSeq(csv, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} - if versFlag { - fmt.Println(PrintBanner(version)) - os.Exit(0) +// 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, + Ignore: refresh.Ignore{ + File: splitList(f.ignoreFile), + Dir: splitList(f.ignoreDir), + WatchedExten: splitList(f.ignoreExt), + IgnoreGit: f.gitIgnore, + }, } - var watch *refresh.Engine +} - if len(configPath) != 0 { - // If toml vs yaml - var err error - if strings.Contains(configPath, ".toml") { - watch, err = refresh.NewEngineFromTOML(configPath) - } else if strings.Contains(configPath, ".yaml") { - watch, err = refresh.NewEngineFromYAML(configPath) - } - if err != nil { - slog.Error("Error reading config file", "err", err) - } - } else { - ignore := refresh.Ignore{ - File: strings.Split(ignoreFile, ","), - Dir: strings.Split(ignoreDir, ","), - WatchedExten: strings.Split(ignoreExt, ","), - IgnoreGit: gitIgnore, - } - // Debounce string to int - debounceThreshold, err := strconv.Atoi(debounce) - if err != nil { - fmt.Println("Error converting debounce to int") - os.Exit(1) - } - config := refresh.Config{ - RootPath: rootPath, - ExecList: strings.Split(execCommand, ","), - LogLevel: logLevel, - Ignore: ignore, - Debounce: debounceThreshold, - } - watch, err = refresh.NewEngineFromConfig(config) - if err != nil { - fmt.Println(err) - os.Exit(1) - } +// newEngine builds an engine from a config file when -f is given, otherwise from +// the individual flags. +func newEngine(f cliFlags) (*refresh.Engine, error) { + if f.configPath != "" { + switch { + case strings.HasSuffix(f.configPath, ".toml"): + return refresh.NewEngineFromTOML(f.configPath) + case strings.HasSuffix(f.configPath, ".yaml"), strings.HasSuffix(f.configPath, ".yml"): + return refresh.NewEngineFromYAML(f.configPath) + default: + return nil, fmt.Errorf("unsupported config file %q (want .toml or .yaml)", f.configPath) } + } + return refresh.NewEngineFromConfig(f.toConfig()) +} - err := watch.Start() - if err != nil { - os.Exit(1) - } - <-make(chan struct{}) +func main() { + f, err := parseFlags(os.Args[1:]) + if err != nil { + os.Exit(2) + } + if f.version { + fmt.Println(PrintBanner(version)) + return + } + + watch, err := newEngine(f) + if err != nil { + slog.Error("failed to configure refresh", "err", err) + os.Exit(1) + } + // Start blocks until a signal triggers shutdown. + if err := watch.Start(); err != nil { + slog.Error("refresh exited with error", "err", err) + os.Exit(1) + } } func PrintBanner(ver string) string { return fmt.Sprintf(` ___ ___________ __________ __ / _ \/ __/ __/ _ \/ __/ __/ // / - / , _/ _// _// , _/ _/_\ \/ _ / -/_/|_/___/_/ /_/|_/___/___/_//_/ CLI v%s + / , _/ _// _// , _/ _/_\ \/ _ / +/_/|_/___/_/ /_/|_/___/___/_//_/ CLI v%s `, ver) } diff --git a/cmd/refresh/main_test.go b/cmd/refresh/main_test.go new file mode 100644 index 0000000..af005ff --- /dev/null +++ b/cmd/refresh/main_test.go @@ -0,0 +1,77 @@ +package main + +import ( + "slices" + "testing" +) + +func TestParseFlagsToConfig(t *testing.T) { + f, err := parseFlags([]string{ + "-p", "./svc", + "-e", "go build, REFRESH, ./svc", + "-l", "warn", + "-d", "250", + "-id", ".git, vendor", + "-ie", "*.go", + "-git", + }) + if err != nil { + t.Fatalf("parseFlags: %v", err) + } + + cfg := f.toConfig() + if cfg.RootPath != "./svc" { + t.Errorf("RootPath = %q, want ./svc", cfg.RootPath) + } + if cfg.LogLevel != "warn" { + t.Errorf("LogLevel = %q, want warn", cfg.LogLevel) + } + if cfg.Debounce != 250 { + t.Errorf("Debounce = %d, want 250", cfg.Debounce) + } + if !cfg.Ignore.IgnoreGit { + t.Error("IgnoreGit = false, want true") + } + if want := []string{"go build", "REFRESH", "./svc"}; !slices.Equal(cfg.ExecList, want) { + t.Errorf("ExecList = %v, want %v", cfg.ExecList, want) + } + if want := []string{".git", "vendor"}; !slices.Equal(cfg.Ignore.Dir, want) { + t.Errorf("Ignore.Dir = %v, want %v", cfg.Ignore.Dir, want) + } + if want := []string{"*.go"}; !slices.Equal(cfg.Ignore.WatchedExten, want) { + t.Errorf("Ignore.WatchedExten = %v, want %v", cfg.Ignore.WatchedExten, want) + } +} + +func TestSplitListDropsEmpties(t *testing.T) { + // An unset flag must yield nil, not [""] (the previous bug, which polluted + // the ignore lists with an empty string). + if got := splitList(""); got != nil { + t.Errorf("splitList(\"\") = %v, want nil", got) + } + if got := splitList("a, ,b,"); !slices.Equal(got, []string{"a", "b"}) { + t.Errorf("splitList = %v, want [a b]", got) + } +} + +func TestNewEngineRejectsUnsupportedConfigExtension(t *testing.T) { + if _, err := newEngine(cliFlags{configPath: "config.json"}); err == nil { + t.Fatal("expected error for unsupported config extension") + } +} + +func TestNewEngineFromFlags(t *testing.T) { + eng, err := newEngine(cliFlags{ + rootPath: ".", + execCommand: "sleep 1", + logLevel: "mute", + debounce: 100, + }) + if err != nil { + t.Fatalf("newEngine: %v", err) + } + // The single command should have been promoted to the primary process. + if execs := eng.ProcessManager.GetExecutes(); !slices.Equal(execs, []string{"sleep 1"}) { + t.Errorf("executes = %v, want [sleep 1]", execs) + } +} diff --git a/engine/builder_test.go b/engine/builder_test.go new file mode 100644 index 0000000..2d1b9a9 --- /dev/null +++ b/engine/builder_test.go @@ -0,0 +1,118 @@ +package engine + +import ( + "bytes" + "log/slog" + "slices" + "strings" + "testing" + + "github.com/atterpac/refresh/process" +) + +func TestDefaultEngineConfig(t *testing.T) { + c := DefaultEngineConfig() + if c.RootPath != "." { + t.Errorf("RootPath = %q, want .", c.RootPath) + } + if c.LogLevel != "warn" { + t.Errorf("LogLevel = %q, want warn", c.LogLevel) + } + if c.Debounce != 1000 { + t.Errorf("Debounce = %d, want 1000", c.Debounce) + } + if !c.Ignore.IgnoreGit { + t.Error("IgnoreGit = false, want true") + } + if !slices.Contains(c.Ignore.Dir, ".git") { + t.Errorf("Ignore.Dir = %v, want it to contain .git", c.Ignore.Dir) + } +} + +func TestConfigBuilders(t *testing.T) { + c := DefaultEngineConfig() + c.WithRootPath("./app"). + WithLogLevel("debug"). + WithDebounce(50). + WithIgnoreDirs([]string{"x"}). + WithIgnoreFiles([]string{"y.go"}). + WithIgnoreGit(false). + WithWatchedExtensions([]string{"*.go"}). + WithExecuteCommand(process.Execute{Cmd: "./app", Type: process.Primary}) + + if c.RootPath != "./app" { + t.Errorf("RootPath = %q", c.RootPath) + } + if c.LogLevel != "debug" { + t.Errorf("LogLevel = %q", c.LogLevel) + } + if c.Debounce != 50 { + t.Errorf("Debounce = %d", c.Debounce) + } + if c.Ignore.IgnoreGit { + t.Error("IgnoreGit should be false after WithIgnoreGit(false)") + } + if !slices.Equal(c.Ignore.Dir, []string{"x"}) { + t.Errorf("Ignore.Dir = %v", c.Ignore.Dir) + } + if !slices.Equal(c.Ignore.File, []string{"y.go"}) { + t.Errorf("Ignore.File = %v", c.Ignore.File) + } + if !slices.Equal(c.Ignore.WatchedExten, []string{"*.go"}) { + t.Errorf("Ignore.WatchedExten = %v", c.Ignore.WatchedExten) + } + if len(c.ExecStruct) != 1 || c.ExecStruct[0].Cmd != "./app" { + t.Errorf("ExecStruct = %+v", c.ExecStruct) + } +} + +func TestWithIgnoreReplacesWholeStruct(t *testing.T) { + c := DefaultEngineConfig() + c.WithIgnore(Ignore{Dir: []string{"z"}}) + if !slices.Equal(c.Ignore.Dir, []string{"z"}) || c.Ignore.IgnoreGit { + t.Errorf("WithIgnore did not replace the struct: %+v", c.Ignore) + } +} + +// TestEngineLoggingControls covers SetLogger plus the engine-level runtime +// controls (SetLogLevel/DisableLogs/EnableLogs) end to end, by routing a +// caller-supplied logger into a buffer. +func TestEngineLoggingControls(t *testing.T) { + var buf bytes.Buffer + custom := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + eng := &Engine{Config: Config{LogLevel: "info"}} + eng.SetLogger(custom) + + eng.Config.Slog.Info("hello") + if !strings.Contains(buf.String(), "hello") { + t.Fatalf("SetLogger did not route output: %q", buf.String()) + } + + buf.Reset() + eng.DisableLogs() + eng.Config.Slog.Error("suppressed") + if buf.Len() != 0 { + t.Errorf("DisableLogs did not mute output: %q", buf.String()) + } + + buf.Reset() + eng.EnableLogs() + eng.SetLogLevel("debug") + eng.Config.Slog.Debug("verbose") + if !strings.Contains(buf.String(), "verbose") { + t.Errorf("EnableLogs + SetLogLevel(debug) failed: %q", buf.String()) + } +} + +// TestEngineLogControlsNilSafe verifies the controls are safe before a logger is +// installed and that SetLogLevel records the level for later initialization. +func TestEngineLogControlsNilSafe(t *testing.T) { + eng := &Engine{} + eng.DisableLogs() // must not panic + eng.EnableLogs() + eng.SetLogLevel("warn") + if eng.Config.LogLevel != "warn" { + t.Errorf("SetLogLevel before init = %q, want warn recorded", eng.Config.LogLevel) + } +} diff --git a/engine/config.go b/engine/config.go index 95df135..dedbc03 100644 --- a/engine/config.go +++ b/engine/config.go @@ -3,10 +3,9 @@ package engine import ( "bufio" "errors" - "fmt" "log/slog" "os" - "runtime" + "path/filepath" "strings" "github.com/BurntSushi/toml" @@ -15,18 +14,15 @@ import ( ) type Config struct { - RootPath string `toml:"root_path" yaml:"root_path"` - BackgroundStruct process.Execute `toml:"background" yaml:"background"` - BackgroundCallback func() bool `toml:"-" yaml:"-"` - Ignore Ignore `toml:"ignore" yaml:"ignore"` - ExecStruct []process.Execute `toml:"executes" yaml:"executes"` - 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 - ignoreMap ignoreMap - externalSlog bool + RootPath string `toml:"root_path" yaml:"root_path"` + BackgroundStruct process.Execute `toml:"background" yaml:"background"` + Ignore Ignore `toml:"ignore" yaml:"ignore"` + ExecStruct []process.Execute `toml:"executes" yaml:"executes"` + 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 } func DefaultEngineConfig() Config { @@ -90,7 +86,7 @@ func (c *Config) WithExecuteCommand(cmd process.Execute) *Config { // Reads a config.toml file and returns the engine func (engine *Engine) readConfigFile(path string) (*Engine, error) { if _, err := toml.DecodeFile(path, &engine); err != nil { - slog.Error("Error reading config file", err) + slog.Error("reading config file", "path", path, "err", err) return nil, err } return engine, nil @@ -99,13 +95,12 @@ func (engine *Engine) readConfigFile(path string) (*Engine, error) { func (engine *Engine) readConfigYaml(path string) (*Engine, error) { file, err := os.ReadFile(path) if err != nil { - slog.Error("Error reading config file", err) + slog.Error("reading config file", "path", path, "err", err) return nil, err } err = yaml.Unmarshal(file, &engine) if err != nil { - slog.Error("Error reading config file", err) - slog.Error(err.Error()) + slog.Error("parsing yaml config", "path", path, "err", err) return nil, err } return engine, nil @@ -114,18 +109,15 @@ func (engine *Engine) readConfigYaml(path string) (*Engine, error) { func (engine *Engine) StringtoConfigYAML(yamlString string) error { err := yaml.Unmarshal([]byte(yamlString), &engine) if err != nil { - slog.Error("Error reading config file", err) - slog.Error(err.Error()) + slog.Error("parsing yaml config string", "err", err) return err } return nil } func (engine *Engine) StringtoConfigTOML(tomlString string) error { - err := yaml.Unmarshal([]byte(tomlString), &engine) - if err != nil { - slog.Error("Error reading config file", err) - slog.Error(err.Error()) + if _, err := toml.Decode(tomlString, &engine); err != nil { + slog.Error("parsing toml config string", "err", err) return err } return nil @@ -133,96 +125,124 @@ func (engine *Engine) StringtoConfigTOML(tomlString string) error { // Verify required data is present in config func (engine *Engine) verifyConfig() error { - slog.Debug("Verifying Config") + slog.Debug("verifying config") if engine.Config.RootPath == "" { - slog.Error("Required Root Path is not set") - return errors.New("Required Root Path is not set") + return errors.New("root path is required") } - err := engine.verifyExecute() - if err != nil { + engine.normalizeExecutes() + if err := engine.verifyExecute(); err != nil { return err } - slog.Debug("Config Verified") - // Change directory executes are called in to match root directory - cleaned := cleanDirectory(engine.Config.RootPath) - slog.Info("Changing Working Directory", "dir", cleaned) - changeWorkingDirectory(cleaned) + slog.Debug("config verified") return nil } -// Verify execute structs +// normalizeExecutes converts the simpler ExecList (string) form into the +// canonical ExecStruct form when no struct executes were supplied, so the rest +// of the engine only ever deals with one representation. +func (engine *Engine) normalizeExecutes() { + if len(engine.Config.ExecStruct) == 0 && len(engine.Config.ExecList) > 0 { + engine.Config.ExecStruct = execListToSpecs(engine.Config.ExecList) + } +} + +// execListToSpecs maps an ExecList into Execute structs. Commands are blocking +// by default; REFRESH_EXEC marks the following command as the primary process, +// and KILL_EXEC is accepted but ignored (the supervisor handles stale kills). +// If no REFRESH_EXEC marker is present, the last command becomes the primary so +// a bare list still runs something long-lived. +func execListToSpecs(list []string) []process.Execute { + specs := make([]process.Execute, 0, len(list)) + primaryNext := false + for _, raw := range list { + cmd := strings.TrimSpace(raw) + switch cmd { + case "": + continue + case process.KILL_EXEC: + continue + case process.REFRESH_EXEC: + primaryNext = true + continue + } + execType := process.Blocking + if primaryNext { + execType = process.Primary + primaryNext = false + } + specs = append(specs, process.Execute{Cmd: cmd, Type: execType}) + } + + if len(specs) > 0 && !hasPrimary(specs) { + specs[len(specs)-1].Type = process.Primary + } + return specs +} + +func hasPrimary(specs []process.Execute) bool { + for _, s := range specs { + if s.Type == process.Primary { + return true + } + } + return false +} + +// verifyExecute ensures at least one execute is configured and that no more than +// one primary process is declared. func (engine *Engine) verifyExecute() error { - var primary bool - if len(engine.Config.ExecList) == 2 && len(engine.Config.ExecStruct) < 2 { - return errors.New("Execute list or struct's must be provided in the refresh config") - } - if engine.Config.ExecList == nil { - for _, exe := range engine.Config.ExecStruct { - if exe.Type == "primary" { - if primary { - return errors.New("Only one primary execute can be set") - } - primary = true - } + if len(engine.Config.ExecStruct) == 0 { + return errors.New("at least one execute must be provided via ExecStruct or ExecList") + } + primary := 0 + for _, exe := range engine.Config.ExecStruct { + if exe.Type == process.Primary { + primary++ } } + if primary > 1 { + return errors.New("only one primary execute can be set") + } return nil } -func readGitIgnore(path string) map[string]struct{} { - file, err := os.Open(path + "/.gitignore") +// readGitIgnore reads the root .gitignore and returns its entries as globs that +// patternMatch can apply. Returns nil if there is no .gitignore. +func readGitIgnore(path string) []string { + file, err := os.Open(filepath.Join(path, ".gitignore")) if err != nil { return nil } defer file.Close() - slog.Debug("Reading .gitignore") + slog.Debug("reading .gitignore") scanner := bufio.NewScanner(file) - var linesMap = make(map[string]struct{}) + var patterns []string for scanner.Scan() { - // Check if line is a comment - if strings.HasPrefix(scanner.Text(), "#") { + line := strings.TrimSpace(scanner.Text()) + // Skip comments and blank lines. + if line == "" || strings.HasPrefix(line, "#") { continue } - // Check if line is empty - if len(scanner.Text()) == 0 { - continue - } - - line := scanner.Text() - // Check if line does not start with '*' + // Normalize to a glob so patternMatch can use it. if !strings.HasPrefix(line, "*") { - // Add asterisk to the beginning of line line = "*" + line } - // Add to the map - linesMap[line] = struct{}{} - } - slog.Debug(fmt.Sprintf("Read %v lines from .gitignore", linesMap)) - return linesMap -} - -func cleanDirectory(path string) string { - cleaned := strings.TrimPrefix(path, ".") - cleaned = strings.TrimPrefix(cleaned, "/") - if runtime.GOOS == "windows" { - cleaned = strings.TrimPrefix(cleaned, `\`) // Windows >:( - } - wd, err := os.Getwd() - if err != nil { - slog.Error("Getting Working Directory") + patterns = append(patterns, line) } - return wd + "/" + cleaned -} - -func changeWorkingDirectory(path string) { - err := os.Chdir(path) - if err != nil { - slog.Error("Setting new directory", "dir", path) + if err := scanner.Err(); err != nil { + slog.Debug("reading .gitignore", "err", err) } + slog.Debug("read .gitignore", "patterns", len(patterns)) + return patterns } 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) + } for _, ex := range e.Config.ExecStruct { - e.ProcessManager.AddProcess(ex.Cmd, string(ex.Type), ex.ChangeDir) + _ = e.ProcessManager.AddProcess(ex.Cmd, string(ex.Type), ex.ChangeDir) } } diff --git a/engine/config_test.go b/engine/config_test.go new file mode 100644 index 0000000..8daecda --- /dev/null +++ b/engine/config_test.go @@ -0,0 +1,107 @@ +package engine + +import ( + "testing" + + "github.com/atterpac/refresh/process" +) + +func TestExecListToSpecs(t *testing.T) { + tests := []struct { + name string + in []string + want []process.Execute + }{ + { + name: "refresh marker promotes following command to primary", + in: []string{"go mod tidy", "go build -o ./app", "KILL_STALE", "REFRESH", "./app"}, + want: []process.Execute{ + {Cmd: "go mod tidy", Type: process.Blocking}, + {Cmd: "go build -o ./app", Type: process.Blocking}, + {Cmd: "./app", Type: process.Primary}, + }, + }, + { + name: "no refresh marker makes the last command primary", + in: []string{"go build -o ./app", "./app"}, + want: []process.Execute{ + {Cmd: "go build -o ./app", Type: process.Blocking}, + {Cmd: "./app", Type: process.Primary}, + }, + }, + { + name: "whitespace and empties are trimmed and dropped", + in: []string{" go build ", "", "REFRESH", " ./app "}, + want: []process.Execute{ + {Cmd: "go build", Type: process.Blocking}, + {Cmd: "./app", Type: process.Primary}, + }, + }, + { + name: "empty list yields no specs", + in: []string{""}, + want: []process.Execute{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := execListToSpecs(tt.in) + if len(got) != len(tt.want) { + t.Fatalf("got %d specs, want %d: %+v", len(got), len(tt.want), got) + } + for i := range tt.want { + if got[i] != tt.want[i] { + t.Errorf("spec[%d] = %+v, want %+v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestVerifyExecuteRejectsMultiplePrimaries(t *testing.T) { + e := &Engine{Config: Config{ + RootPath: ".", + ExecStruct: []process.Execute{ + {Cmd: "a", Type: process.Primary}, + {Cmd: "b", Type: process.Primary}, + }, + }} + if err := e.verifyExecute(); err == nil { + t.Fatal("expected error for two primary executes") + } +} + +func TestVerifyExecuteRequiresAtLeastOne(t *testing.T) { + e := &Engine{Config: Config{RootPath: "."}} + if err := e.verifyExecute(); err == nil { + t.Fatal("expected error when no executes are configured") + } +} + +func TestBackgroundStructBecomesProcess(t *testing.T) { + eng, err := NewEngineFromConfig(Config{ + RootPath: ".", + BackgroundStruct: process.Execute{Cmd: "echo bg"}, + ExecStruct: []process.Execute{{Cmd: "./app", Type: process.Primary}}, + }) + if err != nil { + t.Fatal(err) + } + // The background command is registered ahead of the configured executes. + if got := eng.ProcessManager.GetExecutes(); len(got) != 2 || got[0] != "echo bg" || got[1] != "./app" { + t.Errorf("executes = %v, want [echo bg, ./app]", got) + } +} + +func TestNormalizeExecutesPrefersStruct(t *testing.T) { + e := &Engine{Config: Config{ + RootPath: ".", + ExecStruct: []process.Execute{{Cmd: "./app", Type: process.Primary}}, + ExecList: []string{"should", "be", "ignored"}, + }} + e.normalizeExecutes() + if len(e.Config.ExecStruct) != 1 || e.Config.ExecStruct[0].Cmd != "./app" { + t.Fatalf("ExecStruct should be preferred over ExecList: %+v", e.Config.ExecStruct) + } +} diff --git a/engine/configfile_test.go b/engine/configfile_test.go new file mode 100644 index 0000000..2068e2e --- /dev/null +++ b/engine/configfile_test.go @@ -0,0 +1,107 @@ +package engine + +import ( + "os" + "path/filepath" + "testing" +) + +const tomlConfig = ` +[config] +root_path = "." +log_level = "warn" +debounce = 250 + +[config.ignore] +watched_extension = ["*.go"] +dir = ["vendor"] + +[[config.executes]] +cmd = "go build -o ./app" +type = "blocking" + +[[config.executes]] +cmd = "./app" +type = "primary" +` + +const yamlConfig = ` +config: + root_path: "." + log_level: warn + debounce: 250 + ignore: + watched_extension: ["*.go"] + dir: ["vendor"] + executes: + - cmd: "go build -o ./app" + type: blocking + - cmd: "./app" + type: primary +` + +func assertLoaded(t *testing.T, eng *Engine) { + t.Helper() + if eng.Config.LogLevel != "warn" { + t.Errorf("LogLevel = %q, want warn", eng.Config.LogLevel) + } + if eng.Config.Debounce != 250 { + t.Errorf("Debounce = %d, want 250", eng.Config.Debounce) + } + if got := eng.ProcessManager.GetExecutes(); len(got) != 2 || + got[0] != "go build -o ./app" || got[1] != "./app" { + t.Errorf("executes = %v, want [go build..., ./app]", got) + } +} + +func TestNewEngineFromTOML(t *testing.T) { + path := filepath.Join(t.TempDir(), "refresh.toml") + if err := os.WriteFile(path, []byte(tomlConfig), 0o644); err != nil { + t.Fatal(err) + } + eng, err := NewEngineFromTOML(path) + if err != nil { + t.Fatalf("NewEngineFromTOML: %v", err) + } + assertLoaded(t, eng) +} + +func TestNewEngineFromYAML(t *testing.T) { + path := filepath.Join(t.TempDir(), "refresh.yaml") + if err := os.WriteFile(path, []byte(yamlConfig), 0o644); err != nil { + t.Fatal(err) + } + eng, err := NewEngineFromYAML(path) + if err != nil { + t.Fatalf("NewEngineFromYAML: %v", err) + } + assertLoaded(t, eng) +} + +// TestStringToConfigTOML guards the fix for the bug where StringtoConfigTOML +// decoded TOML with the YAML unmarshaler (which would leave the config empty). +func TestStringToConfigTOML(t *testing.T) { + e := &Engine{} + if err := e.StringtoConfigTOML("[config]\nroot_path = \"from-toml\"\ndebounce = 42\n"); err != nil { + t.Fatalf("StringtoConfigTOML: %v", err) + } + if e.Config.RootPath != "from-toml" { + t.Errorf("RootPath = %q, want from-toml (TOML was not parsed as TOML)", e.Config.RootPath) + } + if e.Config.Debounce != 42 { + t.Errorf("Debounce = %d, want 42", e.Config.Debounce) + } +} + +func TestStringToConfigYAML(t *testing.T) { + e := &Engine{} + if err := e.StringtoConfigYAML("config:\n root_path: from-yaml\n debounce: 7\n"); err != nil { + t.Fatalf("StringtoConfigYAML: %v", err) + } + if e.Config.RootPath != "from-yaml" { + t.Errorf("RootPath = %q, want from-yaml", e.Config.RootPath) + } + if e.Config.Debounce != 7 { + t.Errorf("Debounce = %d, want 7", e.Config.Debounce) + } +} diff --git a/engine/engine.go b/engine/engine.go index df429d8..5465000 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -1,107 +1,137 @@ -//go:build windows || linux || darwin -// +build windows linux darwin - package engine import ( "context" "errors" - "io" + "fmt" "log/slog" "os" "os/signal" + "syscall" "time" "github.com/atterpac/refresh/process" - "github.com/rjeczalik/notify" ) type Engine struct { - PrimaryProcess process.Process - BgProcess process.Process - Chan chan notify.EventInfo - Active bool Config Config `toml:"config" yaml:"config"` - ProcessLogFile *os.File - ProcessLogPipe io.ReadCloser ProcessManager *process.ProcessManager ctx context.Context cancel context.CancelFunc - isPaused bool + log *dynamicLogger } -func (engine *Engine) Start() error { - config := engine.Config - slog.Info("Refresh Starting...") - if config.Ignore.IgnoreGit { - config.ignoreMap.git = readGitIgnore(config.RootPath) +// initLogger builds the engine's logger from the configured level (and an +// optional caller-supplied logger) and installs it as the slog default so that +// every package — including process — routes through the same controllable +// handler. Called by all constructors. +func (engine *Engine) initLogger() { + engine.log = newDynamicLogger(engine.Config.LogLevel, engine.Config.Slog) + engine.Config.Slog = engine.log.logger + slog.SetDefault(engine.log.logger) +} + +// SetLogLevel changes the active log level at runtime. Accepts +// "debug", "info", "warn", "error", or "mute". Safe to call from any goroutine. +func (engine *Engine) SetLogLevel(level string) { + if engine.log == nil { + engine.Config.LogLevel = level + return } + engine.log.SetLevel(level) +} - waitTime := time.Duration(engine.Config.BackgroundStruct.DelayNext) * time.Millisecond +// DisableLogs mutes all engine output without discarding the configured level, +// so EnableLogs restores the previous verbosity. +func (engine *Engine) DisableLogs() { + if engine.log != nil { + engine.log.Disable() + } +} + +// EnableLogs resumes output after DisableLogs. +func (engine *Engine) EnableLogs() { + if engine.log != nil { + engine.log.Enable() + } +} + +// Start runs the initial process pass, begins watching the filesystem, and then +// blocks on a single supervisor loop that serializes reloads and shutdown. It +// returns nil on a clean (signal-triggered) exit, or an error if the initial +// startup fails. +func (engine *Engine) Start() error { + slog.Info("refresh starting") + + if len(EventMap) == 0 { + return errors.New("file watching is not supported on this platform") + } + 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 engine.cancel = cancel - time.Sleep(waitTime) - trapChan := make(chan error) - go engine.sigTrap(trapChan) - go engine.ProcessManager.StartProcess(engine.ctx, engine.cancel) - go func() { - <-ctx.Done() - if ctx.Err() == context.Canceled { - if !engine.ProcessManager.FirstRun { - slog.Error("Could not refresh processes due to execution errors") - newCtx, newCancel := context.WithCancel(context.Background()) - engine.ctx = newCtx - engine.cancel = newCancel - return + // Initial pass over all configured processes. + if err := engine.ProcessManager.Start(ctx); err != nil { + engine.ProcessManager.Shutdown() + cancel() + return fmt.Errorf("starting processes: %w", err) + } + + engine.trapSignals() + + // Reload requests from the watcher arrive here; the buffer of one plus a + // non-blocking send coalesces bursts into a single pending reload. + reload := make(chan struct{}, 1) + if err := engine.startWatcher(ctx, reload); err != nil { + engine.Stop() + engine.ProcessManager.Shutdown() + return err + } + + // Supervisor loop: the only goroutine that drives process lifecycle, so the + // process manager needs no locking around its runtime state. + for { + select { + case <-ctx.Done(): + engine.ProcessManager.Shutdown() + slog.Info("refresh stopped") + return nil + case <-reload: + slog.Info("change detected, reloading") + if err := engine.ProcessManager.Reload(ctx); err != nil { + slog.Error("reload failed", "err", err) } - engine.Stop() - trapChan <- errors.New("An error occured while starting proceses") } - }() - - eventManager := NewEventManager(engine, engine.Config.Debounce) - go engine.watch(eventManager) - return <-trapChan + } } +// Stop requests a graceful shutdown. The supervisor loop performs the actual +// process teardown when the context is cancelled. func (engine *Engine) Stop() { - engine.ProcessManager.KillProcesses() - engine.cancel() - notify.Stop(engine.Chan) + if engine.cancel != nil { + engine.cancel() + } } +// SetLogger replaces the engine's logger with a caller-supplied one. The logger +// is still wrapped so SetLogLevel/DisableLogs/EnableLogs continue to work. func (engine *Engine) SetLogger(logger *slog.Logger) { - engine.Config.Slog = logger - engine.Config.externalSlog = true -} - -// This is out of date -func NewEngine(rootPath, execCommand, logLevel string, execList []string, ignore Ignore, debounce int, chunkSize string) (*Engine, error) { - engine := &Engine{} - engine.Config = Config{ - RootPath: rootPath, - ExecList: execList, - LogLevel: logLevel, - Ignore: ignore, - Debounce: debounce, - } - err := engine.verifyConfig() - if err != nil { - return nil, err - } - engine.ProcessManager = process.NewProcessManager() - engine.generateProcess() - _ = engine.ProcessManager.SetRootDirectory(engine.Config.RootPath) - return engine, nil + engine.log = newDynamicLogger(engine.Config.LogLevel, logger) + engine.Config.Slog = engine.log.logger + slog.SetDefault(engine.log.logger) } func NewEngineFromConfig(options Config) (*Engine, error) { engine := &Engine{} engine.Config = options - engine.Config.ignoreMap = convertToIgnoreMap(engine.Config.Ignore) + engine.initLogger() err := engine.verifyConfig() if err != nil { return nil, err @@ -113,60 +143,43 @@ func NewEngineFromConfig(options Config) (*Engine, error) { } func NewEngineFromTOML(confPath string) (*Engine, error) { - engine := Engine{} - _, err := engine.readConfigFile(confPath) - if err != nil { + engine := &Engine{} + if _, err := engine.readConfigFile(confPath); err != nil { return nil, err } - config := engine.Config - config.Slog = newLogger(config.LogLevel) - config.externalSlog = false - slog.SetDefault(config.Slog) - engine.Config.ignoreMap = convertToIgnoreMap(engine.Config.Ignore) - engine.Config.externalSlog = false - err = engine.verifyConfig() - if err != nil { + engine.initLogger() + if err := engine.verifyConfig(); err != nil { return nil, err } engine.ProcessManager = process.NewProcessManager() engine.generateProcess() - return &engine, nil + _ = engine.ProcessManager.SetRootDirectory(engine.Config.RootPath) + return engine, nil } func NewEngineFromYAML(confPath string) (*Engine, error) { - engine := Engine{} - _, err := engine.readConfigYaml(confPath) - if err != nil { + engine := &Engine{} + if _, err := engine.readConfigYaml(confPath); err != nil { return nil, err } - config := engine.Config - config.Slog = newLogger(config.LogLevel) - config.externalSlog = false - slog.SetDefault(config.Slog) - engine.Config.ignoreMap = convertToIgnoreMap(engine.Config.Ignore) - engine.Config.externalSlog = false - err = engine.verifyConfig() - if err != nil { + engine.initLogger() + if err := engine.verifyConfig(); err != nil { return nil, err } engine.ProcessManager = process.NewProcessManager() engine.generateProcess() _ = engine.ProcessManager.SetRootDirectory(engine.Config.RootPath) - return &engine, nil -} - -func (engine *Engine) AttachBackgroundCallback(callback func() bool) *Engine { - engine.Config.BackgroundCallback = callback - return engine + return engine, nil } -func (engine *Engine) sigTrap(ch chan error) { +// trapSignals cancels the engine context on the first interrupt/terminate +// signal, which lets the supervisor loop tear everything down gracefully. +func (engine *Engine) trapSignals() { signalChan := make(chan os.Signal, 1) - signal.Notify(signalChan, os.Interrupt) + signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM) go func() { sig := <-signalChan - slog.Warn("Graceful Exit Requested", "signal", sig) + slog.Warn("graceful exit requested", "signal", sig) engine.Stop() - ch <- errors.New("Graceful Exit Requested") }() } diff --git a/engine/errorpaths_test.go b/engine/errorpaths_test.go new file mode 100644 index 0000000..aab312c --- /dev/null +++ b/engine/errorpaths_test.go @@ -0,0 +1,57 @@ +package engine + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewEngineFromTOMLErrors(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + if _, err := NewEngineFromTOML(filepath.Join(t.TempDir(), "nope.toml")); err == nil { + t.Fatal("expected error for missing TOML file") + } + }) + t.Run("malformed", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad.toml") + if err := os.WriteFile(path, []byte("[unterminated"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := NewEngineFromTOML(path); err == nil { + t.Fatal("expected error for malformed TOML") + } + }) +} + +func TestNewEngineFromYAMLErrors(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + if _, err := NewEngineFromYAML(filepath.Join(t.TempDir(), "nope.yaml")); err == nil { + t.Fatal("expected error for missing YAML file") + } + }) + t.Run("malformed", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad.yaml") + if err := os.WriteFile(path, []byte("a: b: c"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := NewEngineFromYAML(path); err == nil { + t.Fatal("expected error for malformed YAML") + } + }) +} + +func TestNewEngineFromConfigRequiresRootPath(t *testing.T) { + if _, err := NewEngineFromConfig(Config{ExecStruct: []Execute{{Cmd: "x", Type: Primary}}}); err == nil { + t.Fatal("expected error when RootPath is empty") + } +} + +func TestStringToConfigInvalid(t *testing.T) { + e := &Engine{} + if err := e.StringtoConfigTOML("= 1"); err == nil { + t.Error("expected error for invalid TOML string") + } + if err := e.StringtoConfigYAML("a: b: c"); err == nil { + t.Error("expected error for invalid YAML string") + } +} diff --git a/engine/eventmap_unsupported.go b/engine/eventmap_unsupported.go index d2b798d..fcae292 100644 --- a/engine/eventmap_unsupported.go +++ b/engine/eventmap_unsupported.go @@ -2,7 +2,12 @@ package engine -func init() { - println("Unsupported OS detected. File watching will not work.") - os.Exit(1) -} +import "github.com/rjeczalik/notify" + +// This platform has no event mappings. File watching will not function, but the +// package must still compile. Start() surfaces the lack of support as an error +// rather than calling os.Exit from a library init. +var ( + EventMap = map[notify.Event]eventInfo{} + CallbackMap = map[notify.Event]Event{} +) diff --git a/engine/exports.go b/engine/exports.go new file mode 100644 index 0000000..f385033 --- /dev/null +++ b/engine/exports.go @@ -0,0 +1,30 @@ +package engine + +import "github.com/atterpac/refresh/process" + +// Re-exports of the process package's public types and values, so callers can +// configure an engine entirely through the engine package — matching the +// documented `refresh.Execute{...}` / `refresh.KILL_EXEC` usage without a second +// import. +type ( + Execute = process.Execute + ExecuteType = process.ExecuteType +) + +var ( + Background = process.Background + Once = process.Once + Blocking = process.Blocking + Primary = process.Primary + + // KILL_STALE is a marker execute (struct form) indicating where a stale + // primary should be terminated. The supervisor now restarts the primary + // automatically, so it is accepted for backwards compatibility and is a + // no-op in the run cycle. + KILL_STALE = process.KILL_STALE + + // KILL_EXEC / REFRESH_EXEC are markers for the ExecList (string) form. + // REFRESH_EXEC marks the command that follows it as the primary process. + KILL_EXEC = process.KILL_EXEC + REFRESH_EXEC = process.REFRESH_EXEC +) diff --git a/engine/ignore.go b/engine/ignore.go index bee81dd..e4e7b39 100644 --- a/engine/ignore.go +++ b/engine/ignore.go @@ -3,6 +3,7 @@ package engine import ( "log/slog" "path/filepath" + "slices" "strings" ) @@ -11,43 +12,23 @@ type Ignore struct { File []string `toml:"file" yaml:"file"` WatchedExten []string `toml:"watched_extension" yaml:"watched_extension"` IgnoreGit bool `toml:"git" yaml:"git"` -} -type ignoreMap struct { - dir map[string]struct{} - file map[string]struct{} - extension map[string]struct{} - git map[string]struct{} + // gitPatterns holds globs read from the root .gitignore when IgnoreGit is + // set. Populated by the engine at startup; not user-configured. + gitPatterns []string } -// Runs all ignore checks to decide if reload should happen -// func (i *ignoreMap) checkIgnore(path string) bool { -// slog.Debug("Checking Ignore") -// basePath := filepath.Base(path) -// if isTmp(basePath) { -// return true -// } -// if isIgnoreDir(path, i.dir) { -// return true -// } -// dir := checkIgnoreMap(path, i.dir) -// file := checkIgnoreMap(path, i.file) -// git := checkIgnoreMap(path, i.git) -// return dir || file || git -// return i.shouldIgnore(path) -// } - +// shouldIgnore reports whether a change to path should be skipped. A path is +// considered only if it matches a watched extension; it is then ignored if it +// sits in an ignored directory or matches an ignore-file or .gitignore pattern. func (i *Ignore) shouldIgnore(path string) bool { - if i.isWatchedExtension(path) { - slog.Debug("Checking Watched Extension", "path", path) - if isIgnoreDir(path, i.Dir) || - patternMatch(path, i.Dir) || - patternMatch(path, i.File) { - return true - } - return false + if !i.isWatchedExtension(path) { + return true } - return true + return isIgnoreDir(path, i.Dir) || + patternMatch(path, i.Dir) || + patternMatch(path, i.File) || + patternMatch(path, i.gitPatterns) } func (i *Ignore) isWatchedExtension(path string) bool { @@ -67,52 +48,13 @@ func (i *Ignore) isWatchedExtension(path string) bool { return patternMatch(path, i.WatchedExten) } -// func checkIgnoreMap(path string, rules map[string]struct{}) bool { -// slog.Debug(fmt.Sprintf("Checking map: %v for %s", rules, path)) -// _, ok := rules[path] -// return mapHasItems(rules) && patternMatch(path, rules) || ok -// } -// -// func checkExtension(path string, rules map[string]struct{}) bool { -// slog.Debug(fmt.Sprintf("Checking Extension map: %v for %s", rules, path)) -// return patternMatch(path, rules) -// } - -func mapHasItems(m map[string]struct{}) bool { - return len(m) >= 0 -} - -// Checks if filepath ends in tilde returns true if it does -func isTmp(path string) bool { - return len(path) > 0 && path[len(path)-1] == '~' -} - -// Checks if path contains any directories in the ignore directory config +// isIgnoreDir reports whether any path component exactly matches an ignore rule. func isIgnoreDir(path string, rules []string) bool { - dirs := strings.Split(path, string(filepath.Separator)) - for _, dir := range dirs { - for _, rule := range rules { - if dir == rule { - slog.Debug("Ignore Dir", "dir", dir) - return true - } + for dir := range strings.SplitSeq(path, string(filepath.Separator)) { + if slices.Contains(rules, dir) { + slog.Debug("ignoring directory", "dir", dir) + return true } } return false } - -func convertToIgnoreMap(ignore Ignore) ignoreMap { - return ignoreMap{ - file: convertToMap(ignore.File), - dir: convertToMap(ignore.Dir), - extension: convertToMap(ignore.WatchedExten), - } -} - -func convertToMap(slice []string) map[string]struct{} { - m := make(map[string]struct{}) - for _, v := range slice { - m[v] = struct{}{} - } - return m -} diff --git a/engine/ignore_git_test.go b/engine/ignore_git_test.go new file mode 100644 index 0000000..5477fe6 --- /dev/null +++ b/engine/ignore_git_test.go @@ -0,0 +1,37 @@ +package engine + +import ( + "os" + "path/filepath" + "testing" +) + +// TestIgnoreGitPatternsApplied verifies that .gitignore entries are read and +// actually consulted by shouldIgnore. Previously the patterns were read into a +// map that nothing ever checked, so IgnoreGit was a no-op. +func TestIgnoreGitPatternsApplied(t *testing.T) { + root := t.TempDir() + gitignore := "generated.go\n# a comment\nbuild/\n" + if err := os.WriteFile(filepath.Join(root, ".gitignore"), []byte(gitignore), 0o644); err != nil { + t.Fatal(err) + } + + ig := Ignore{WatchedExten: []string{"*.go"}, IgnoreGit: true} + ig.gitPatterns = readGitIgnore(root) + if len(ig.gitPatterns) == 0 { + t.Fatal("readGitIgnore returned no patterns") + } + + if !ig.shouldIgnore(filepath.Join(root, "generated.go")) { + t.Error("a .gitignore'd file was not ignored") + } + if ig.shouldIgnore(filepath.Join(root, "main.go")) { + t.Error("a non-ignored file was wrongly ignored") + } +} + +func TestReadGitIgnoreMissingFile(t *testing.T) { + if got := readGitIgnore(t.TempDir()); got != nil { + t.Errorf("expected nil for missing .gitignore, got %v", got) + } +} diff --git a/engine/logger.go b/engine/logger.go index 225ad1a..ef7aea3 100644 --- a/engine/logger.go +++ b/engine/logger.go @@ -1,49 +1,107 @@ package engine import ( - "bufio" "context" - "io" "log/slog" "os" + "sync/atomic" "time" "github.com/lmittmann/tint" ) -// SetDefault sets the default logger. -func newLogger(level string) *slog.Logger { - var writer io.Writer = os.Stderr - if level == "mute" { - writer = io.Discard +// dynamicLogger wraps an slog.Logger with two runtime controls that survive +// across handler boundaries (WithAttrs/WithGroup): +// +// - level — the minimum level to emit, changeable at runtime +// - enabled — a master switch to mute/unmute all output +// +// Both are shared by pointer with every derived handler, so a single call to +// SetLevel/Disable/Enable affects the whole logger tree immediately and is safe +// to call from any goroutine. +type dynamicLogger struct { + level *slog.LevelVar + enabled *atomic.Bool + logger *slog.Logger +} + +// switchHandler gates an inner handler behind a dynamic level and an enabled +// flag. Gating in Enabled (rather than rebuilding handlers) keeps level/mute +// changes atomic and lock-free, and applies uniformly even to a caller-supplied +// handler that has its own internal level. +type switchHandler struct { + inner slog.Handler + level *slog.LevelVar + enabled *atomic.Bool +} + +func (h *switchHandler) Enabled(_ context.Context, l slog.Level) bool { + if !h.enabled.Load() { + return false } - return slog.New(tint.NewHandler(writer, &tint.Options{ - Level: getLogLevel(level), - TimeFormat: time.Kitchen, - })) -} - -func printSubProcess(ctx context.Context, pipe io.ReadCloser) { - scanner := bufio.NewScanner(pipe) - defer pipe.Close() - - for { - select { - case <-ctx.Done(): - return - default: - if scanner.Scan() { - println(scanner.Text()) - } else { - if err := scanner.Err(); err != nil { - slog.Debug("Couldnt connect to process log pipe", "err", err) - } - return - } - } + return l >= h.level.Level() +} + +func (h *switchHandler) Handle(ctx context.Context, r slog.Record) error { + return h.inner.Handle(ctx, r) +} + +func (h *switchHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &switchHandler{inner: h.inner.WithAttrs(attrs), level: h.level, enabled: h.enabled} +} + +func (h *switchHandler) WithGroup(name string) slog.Handler { + return &switchHandler{inner: h.inner.WithGroup(name), level: h.level, enabled: h.enabled} +} + +// newDynamicLogger builds a logger from the configured level string. When a +// caller supplies their own *slog.Logger, its handler is wrapped so the enable +// /disable switch and runtime level still apply; otherwise a tinted stderr +// handler is used. +func newDynamicLogger(level string, custom *slog.Logger) *dynamicLogger { + levelVar := new(slog.LevelVar) + enabled := new(atomic.Bool) + + muted := level == "mute" + enabled.Store(!muted) + if muted { + // Keep a sensible threshold so re-enabling logs something useful. + levelVar.Set(slog.LevelInfo) + } else { + levelVar.Set(getLogLevel(level)) + } + + var inner slog.Handler + if custom != nil { + inner = custom.Handler() + } else { + inner = tint.NewHandler(os.Stderr, &tint.Options{ + Level: levelVar, + TimeFormat: time.Kitchen, + }) + } + + return &dynamicLogger{ + level: levelVar, + enabled: enabled, + logger: slog.New(&switchHandler{inner: inner, level: levelVar, enabled: enabled}), } } +// SetLevel changes the minimum emitted level at runtime. The special value +// "mute" disables all output; any other recognized level re-enables it. +func (d *dynamicLogger) SetLevel(level string) { + if level == "mute" { + d.enabled.Store(false) + return + } + d.level.Set(getLogLevel(level)) + d.enabled.Store(true) +} + +func (d *dynamicLogger) Disable() { d.enabled.Store(false) } +func (d *dynamicLogger) Enable() { d.enabled.Store(true) } + func getLogLevel(level string) slog.Level { switch level { case "debug": diff --git a/engine/logger_test.go b/engine/logger_test.go new file mode 100644 index 0000000..7bcb4bd --- /dev/null +++ b/engine/logger_test.go @@ -0,0 +1,110 @@ +package engine + +import ( + "bytes" + "log/slog" + "strings" + "testing" +) + +// newCapture returns a dynamicLogger whose output is captured into buf, so the +// runtime level/enable controls can be asserted on real emitted records. +func newCapture(level string, buf *bytes.Buffer) *dynamicLogger { + custom := slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + return newDynamicLogger(level, custom) +} + +func TestLoggerRespectsConfiguredLevel(t *testing.T) { + var buf bytes.Buffer + d := newCapture("warn", &buf) + + d.logger.Info("hidden") + d.logger.Warn("shown") + + out := buf.String() + if strings.Contains(out, "hidden") { + t.Errorf("info record emitted at warn level: %q", out) + } + if !strings.Contains(out, "shown") { + t.Errorf("warn record missing at warn level: %q", out) + } +} + +func TestSetLevelChangesVerbosityAtRuntime(t *testing.T) { + var buf bytes.Buffer + d := newCapture("error", &buf) + + d.logger.Debug("before") + if strings.Contains(buf.String(), "before") { + t.Fatalf("debug emitted while level=error: %q", buf.String()) + } + + d.SetLevel("debug") + d.logger.Debug("after") + if !strings.Contains(buf.String(), "after") { + t.Errorf("debug not emitted after SetLevel(debug): %q", buf.String()) + } +} + +func TestDisableEnableLogs(t *testing.T) { + var buf bytes.Buffer + d := newCapture("debug", &buf) + + d.Disable() + d.logger.Error("while-disabled") + if buf.Len() != 0 { + t.Errorf("output emitted while disabled: %q", buf.String()) + } + + d.Enable() + d.logger.Error("while-enabled") + if !strings.Contains(buf.String(), "while-enabled") { + t.Errorf("no output after Enable: %q", buf.String()) + } +} + +// TestSwitchHandlerWithAttrsAndGroup ensures the level/enabled switch is +// preserved through derived handlers created by With and WithGroup. +func TestSwitchHandlerWithAttrsAndGroup(t *testing.T) { + var buf bytes.Buffer + d := newCapture("info", &buf) + + derived := d.logger.With("component", "engine").WithGroup("scope") + derived.Info("ready", "id", 1) + if !strings.Contains(buf.String(), "component=engine") || !strings.Contains(buf.String(), "scope.id=1") { + t.Errorf("attrs/group not propagated: %q", buf.String()) + } + + // The shared enabled switch must still gate derived handlers. + buf.Reset() + d.Disable() + derived.Error("suppressed") + if buf.Len() != 0 { + t.Errorf("derived handler ignored Disable: %q", buf.String()) + } +} + +func TestMuteLevelDisablesOutput(t *testing.T) { + var buf bytes.Buffer + d := newCapture("mute", &buf) + + d.logger.Error("muted") + if buf.Len() != 0 { + t.Errorf("mute did not suppress output: %q", buf.String()) + } + + // "mute" must remain recoverable: switching to a real level re-enables. + d.SetLevel("info") + d.logger.Info("recovered") + if !strings.Contains(buf.String(), "recovered") { + t.Errorf("output not restored after leaving mute: %q", buf.String()) + } + + // SetLevel("mute") at runtime must also disable output. + buf.Reset() + d.SetLevel("mute") + d.logger.Error("muted-again") + if buf.Len() != 0 { + t.Errorf("SetLevel(\"mute\") did not disable output: %q", buf.String()) + } +} diff --git a/engine/start_test.go b/engine/start_test.go new file mode 100644 index 0000000..b34d42a --- /dev/null +++ b/engine/start_test.go @@ -0,0 +1,26 @@ +//go:build linux || darwin + +package engine + +import "testing" + +// TestEngineStartFailsWhenBootProcessFails verifies that a failure during the +// initial process pass propagates out of Start as an error (rather than the +// engine silently entering its watch loop). +func TestEngineStartFailsWhenBootProcessFails(t *testing.T) { + cfg := Config{ + RootPath: t.TempDir(), + LogLevel: "mute", + Debounce: 100, + Ignore: Ignore{WatchedExten: []string{"*.go"}}, + // "false" exits non-zero, so the blocking boot step fails. + ExecStruct: []Execute{{Cmd: "false", Type: Blocking}}, + } + eng, err := NewEngineFromConfig(cfg) + if err != nil { + t.Fatalf("NewEngineFromConfig: %v", err) + } + if err := eng.Start(); err == nil { + t.Fatal("expected Start to return an error when the boot process fails") + } +} diff --git a/engine/watch.go b/engine/watch.go index 88e44b6..fc22881 100644 --- a/engine/watch.go +++ b/engine/watch.go @@ -1,151 +1,128 @@ -//go:build linux || darwin || windows -// +build linux darwin windows - package engine import ( "context" + "fmt" "log/slog" - - // "log/slog" "os" "path/filepath" "time" - "github.com/atterpac/refresh/process" "github.com/rjeczalik/notify" ) -type EventManager struct { - engine *Engine - lastEventTime time.Time - debounceThreshold time.Duration - debounceTimer *time.Timer - ctx context.Context - cancel context.CancelFunc +// watcher translates raw filesystem notifications into debounced reload +// requests. A single timer is reset on every reload-eligible event, so a burst +// of writes (editors often emit several per save) collapses into one reload +// fired after the quiet interval — true trailing-edge debounce. +type watcher struct { + engine *Engine + events chan notify.EventInfo + reload chan<- struct{} + debounce time.Duration + root string + timer *time.Timer } -func NewEventManager(engine *Engine, debounce int) *EventManager { - ctx, cancel := context.WithCancel(context.Background()) - em := &EventManager{ - engine: engine, - debounceThreshold: time.Duration(debounce) * time.Millisecond, - ctx: ctx, - cancel: cancel, +// startWatcher begins watching the resolved root directory and spawns the +// watcher goroutine. Reload requests are delivered on reload. +func (engine *Engine) startWatcher(ctx context.Context, reload chan<- struct{}) error { + root := engine.ProcessManager.RootDir + if root == "" { + wd, err := os.Getwd() + if err != nil { + return fmt.Errorf("resolving watch root: %w", err) + } + root = wd } - return em -} -func (em *EventManager) HandleEvent(ei notify.EventInfo) { - eventInfo, ok := EventMap[ei.Event()] - if !ok { - slog.Error("Unknown event", "event", ei.Event()) - return + events := make(chan notify.EventInfo, 16) + if err := notify.Watch(filepath.Join(root, "..."), events, notify.All); err != nil { + return fmt.Errorf("starting file watcher: %w", err) } - if em.engine.Config.Callback != nil { - event := CallbackMap[ei.Event()] - handle := em.engine.Config.Callback(&EventCallback{ - Type: event, - Path: getPath(ei.Path()), - Time: time.Now(), - }) - switch handle { - case EventContinue: - // Continue - case EventBypass: - // slog.Debug("Bypassing event", "event", ei.Event(), "path", ei.Path()) - return - case EventIgnore: - // slog.Debug("Ignoring event", "event", ei.Event(), "path", ei.Path()) - return - default: - } + w := &watcher{ + engine: engine, + events: events, + reload: reload, + debounce: time.Duration(engine.Config.Debounce) * time.Millisecond, + root: root, + } + go w.run(ctx) + slog.Info("watching for changes", "root", root) + return nil +} + +func (w *watcher) run(ctx context.Context) { + defer notify.Stop(w.events) + + // Start with a stopped, drained timer. + w.timer = time.NewTimer(0) + if !w.timer.Stop() { + <-w.timer.C } - if eventInfo.Reload { - newCtx, newCancel := context.WithCancel(context.Background()) - em.engine.ctx = newCtx - em.engine.cancel = newCancel - if em.engine.Config.Ignore.shouldIgnore(ei.Path()) { + for { + select { + case <-ctx.Done(): + w.timer.Stop() return - } - slog.Debug("Event", "event", ei.Event(), "path", ei.Path(), "time", time.Now()) - currentTime := time.Now() - if currentTime.Sub(em.lastEventTime) >= em.debounceThreshold { - slog.Debug("Setting debounce timer", "event", ei.Event(), "path", ei.Path(), "time", time.Now()) - slog.Info("File modified...Refreshing", "file", getPath(ei.Path())) - - // Find the specific process associated with the file change event - for _, p := range em.engine.ProcessManager.Processes { - if p.Type == process.Primary { - // Kill the specific process by canceling its context - if cancel, ok := em.engine.ProcessManager.Cancels[p.Exec]; ok { - cancel() - delete(em.engine.ProcessManager.Ctxs, p.Exec) - delete(em.engine.ProcessManager.Cancels, p.Exec) - } - break - } - } - - // Start a new instance of the process - go em.engine.ProcessManager.StartProcess(em.engine.ctx, em.engine.cancel) - go func() { - <-em.engine.ctx.Done() - if em.engine.ctx.Err() == context.Canceled { - if !em.engine.ProcessManager.FirstRun { - // slog.Error("Could not refresh processes due to build errors") - newCtx, newCancel := context.WithCancel(context.Background()) - em.engine.ctx = newCtx - em.engine.cancel = newCancel - return - } - em.engine.Stop() - } - }() - - em.lastEventTime = currentTime - } else { - // slog.Debug("Debouncing event", "event", ei.Event(), "path", ei.Path(), "time", time.Now()) + case ei := <-w.events: + w.handle(ei) + case <-w.timer.C: + w.signalReload() } } } -func (engine *Engine) watch(eventManager *EventManager) { - engine.Chan = make(chan notify.EventInfo, 5) - defer notify.Stop(engine.Chan) - - wd, err := os.Getwd() - if err != nil { - slog.Error("Getting working directory") +// handle decides whether a single event should (eventually) trigger a reload, +// applying the platform event map, the user callback, and the ignore rules, +// then resets the debounce timer. +func (w *watcher) handle(ei notify.EventInfo) { + info, ok := EventMap[ei.Event()] + if !ok { + slog.Debug("unknown event", "event", ei.Event()) return } - - if err := notify.Watch(wd+"/...", engine.Chan, notify.All); err != nil { - slog.Error("Watch Error", "err", err.Error()) + if !info.Reload { return } - for { - select { - case ei := <-engine.Chan: - eventManager.HandleEvent(ei) + rel := w.relPath(ei.Path()) + + if w.engine.Config.Callback != nil { + switch w.engine.Config.Callback(&EventCallback{ + Type: CallbackMap[ei.Event()], + Path: rel, + Time: time.Now(), + }) { + case EventBypass, EventIgnore: + return } } + if w.engine.Config.Ignore.shouldIgnore(ei.Path()) { + slog.Debug("ignoring change", "path", rel) + return + } + + slog.Debug("change detected", "path", rel, "event", info.Name) + w.timer.Reset(w.debounce) } -func getPath(path string) string { - wd, err := os.Getwd() - if err != nil { - // slog.Error("Getting working directory") - return "" +// signalReload performs a non-blocking send so a reload that is already queued +// is not duplicated; the buffered channel coalesces bursts into one reload. +func (w *watcher) signalReload() { + select { + case w.reload <- struct{}{}: + default: } - relPath, err := filepath.Rel(wd, path) +} + +func (w *watcher) relPath(path string) string { + rel, err := filepath.Rel(w.root, path) if err != nil { - // slog.Error("Getting relative path") - return "" + return path } - return relPath + return rel } diff --git a/engine/watch_test.go b/engine/watch_test.go new file mode 100644 index 0000000..38f1343 --- /dev/null +++ b/engine/watch_test.go @@ -0,0 +1,107 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/atterpac/refresh/process" +) + +// newWatchTestEngine builds a minimal engine whose watcher can run against a +// real temp directory, without starting any processes. +func newWatchTestEngine(t *testing.T, root string, debounceMS int) *Engine { + t.Helper() + e := &Engine{Config: Config{ + RootPath: root, + Debounce: debounceMS, + Ignore: Ignore{WatchedExten: []string{"*.txt"}}, + }} + e.ProcessManager = process.NewProcessManager() + if err := e.ProcessManager.SetRootDirectory(root); err != nil { + t.Fatal(err) + } + return e +} + +func TestWatcherCoalescesBurstIntoSingleReload(t *testing.T) { + root := t.TempDir() + e := newWatchTestEngine(t, root, 200) + + 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) + } + + // A burst of writes well within the debounce window should collapse to one + // reload fired after the quiet interval. + file := filepath.Join(root, "a.txt") + for i := range 5 { + if err := os.WriteFile(file, []byte(strconv.Itoa(i)), 0o644); err != nil { + t.Fatal(err) + } + time.Sleep(15 * time.Millisecond) + } + + // Wait comfortably past the debounce for the trailing-edge fire. + time.Sleep(500 * time.Millisecond) + cancel() + + if got := len(reload); got != 1 { + t.Errorf("expected exactly 1 coalesced reload, got %d", got) + } +} + +func TestWatcherDetectsNestedSubdirectoryChanges(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "app"), 0o755); err != nil { + t.Fatal(err) + } + e := newWatchTestEngine(t, root, 150) + + 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) + } + + // The recursive watch must catch writes in nested directories. + if err := os.WriteFile(filepath.Join(root, "app", "main.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + time.Sleep(400 * time.Millisecond) + cancel() + + if got := len(reload); got != 1 { + t.Errorf("nested change produced %d reloads, want 1", got) + } +} + +func TestWatcherIgnoresUnwatchedExtensions(t *testing.T) { + root := t.TempDir() + e := newWatchTestEngine(t, root, 100) + + 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) + } + + // Only *.txt is watched; a *.log write must not trigger a reload. + if err := os.WriteFile(filepath.Join(root, "ignore.log"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + time.Sleep(300 * time.Millisecond) + cancel() + + if got := len(reload); got != 0 { + t.Errorf("unwatched extension triggered %d reloads, want 0", got) + } +} diff --git a/example/README.md b/example/README.md deleted file mode 100644 index bd1e7e7..0000000 --- a/example/README.md +++ /dev/null @@ -1,12 +0,0 @@ -## Embedded Project Example - -This example showcases how you can use refresh as an embedded library to reload a project. -See the ignore config in main.go and make your way through the nested project folders making changes to see how refresh handles it - -Keep logs to debug to see refresh monitor all files being changed but choose to ignore based on the configured ruleset -Debug logs will show all rules being checked - -Run `go run main.go` to run via the embedded structs setup in main.go file - -Run `refresh -f example.toml` to run via CLI and the provided toml file - diff --git a/example/example.toml b/example/example.toml deleted file mode 100644 index 4c0a078..0000000 --- a/example/example.toml +++ /dev/null @@ -1,43 +0,0 @@ -## Run this example in the refresh/example directory -## refresh -f example.toml -[config] -# Just used in the TUI not required -label = "My Project" -# Relative to this files location -root_path = "./example/test" -# debug | info(default) | warn | error | mute -log_level = "debug" -# Debounce setting for ignoring reptitive file system notifications -debounce = 1000 # Milliseconds - -# Sets what files the watcher should ignore -[config.ignore] -# Directories to ignore -dir = ["ignoreme"] -# Files to ignore -file = ["*ignore.go", "ignoredFile.go"] -# File extensions to watch -watched_extension = ["*.go"] - -# Executes are run in order -# cmd is the command to run -# blocking will block the next command from running until it is complete -# primary will be the command that will persist through even when a file change is detected -# change_dir will change the directory to the root_path prior to the command -[[config.executes]] -cmd="echo 'hello from refresh'" -type="once" - -[[config.executes]] -cmd="go mod tidy" -type="blocking" - -[[config.executes]] -cmd="go build -o ./app" -type="blocking" - - -[[config.executes]] -cmd="./app" -type="primary" - diff --git a/example/example.yaml b/example/example.yaml deleted file mode 100644 index dec5439..0000000 --- a/example/example.yaml +++ /dev/null @@ -1,23 +0,0 @@ -config: - label: My Project - root_path: test - log_level: debug - debounce: 1000 - ignore: - dir: - - ignoreme - file: - - '*ignore.go' - - ignoredFile.go - watched_extension: - - '*.go' - executes: - - cmd: echo 'Hello from refresh' - type: once - - cmd: go mod tidy - type: blocking - - cmd: go build -o ./bin/app - type: blocking - - cmd: ./app - dir: ./bin - type: primary diff --git a/example/go.mod b/example/go.mod deleted file mode 100644 index 526d1a8..0000000 --- a/example/go.mod +++ /dev/null @@ -1,15 +0,0 @@ -module example - -go 1.24.0 - -require github.com/atterpac/refresh v0.1.0 - -require ( - github.com/BurntSushi/toml v1.3.2 // indirect - github.com/lmittmann/tint v1.0.3 // indirect - github.com/rjeczalik/notify v0.9.3 // indirect - golang.org/x/sys v0.18.0 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect -) - -replace github.com/atterpac/refresh => ../ diff --git a/example/go.sum b/example/go.sum deleted file mode 100644 index 96c5235..0000000 --- a/example/go.sum +++ /dev/null @@ -1,13 +0,0 @@ -github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= -github.com/lmittmann/tint v1.0.3 h1:W5PHeA2D8bBJVvabNfQD/XW9HPLZK1XoPZH0cq8NouQ= -github.com/lmittmann/tint v1.0.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= -github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= -golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/example/main.go b/example/main.go deleted file mode 100644 index a2e1ce5..0000000 --- a/example/main.go +++ /dev/null @@ -1,77 +0,0 @@ -package main - -import ( - "time" - - refresh "github.com/atterpac/refresh/engine" - "github.com/atterpac/refresh/process" -) - -func main() { - background := process.Execute{ - Cmd: "pwd", - } - tidy := process.Execute{ - Cmd: "go mod tidy", - Type: process.Background, - } - build := process.Execute{ - Cmd: "go build -o ./bin/myapp", - Type: process.Blocking, - } - kill := process.KILL_STALE - run := process.Execute{ - Cmd: "./myapp", - ChangeDir: "./binn", - Type: process.Primary, - } - ignore := refresh.Ignore{ - File: []string{"ignore.go"}, - Dir: []string{"*/ignore*"}, - WatchedExten: []string{"*.go", "*.mod", "*.js"}, - IgnoreGit: true, - } - _ = refresh.Config{ - RootPath: "./test", - BackgroundStruct: background, - // Below is ran when a reload is triggered before killing the stale version - Ignore: ignore, - Debounce: 1000, - LogLevel: "debug", - ExecStruct: []process.Execute{tidy, build, kill, run}, - Slog: nil, - } - - // watch := refresh.NewEngineFromConfig(config) - watch, err := refresh.NewEngineFromYAML("./example.yaml") - if err != nil { - panic(err) - } - - watch.AttachBackgroundCallback(func() bool { - time.Sleep(5000 * time.Millisecond) - return true - }) - err = watch.Start() - if err != nil { - panic(err) - } - - <-make(chan struct{}) -} - -func RefreshCallback(e *refresh.EventCallback) refresh.EventHandle { - switch e.Type { - case refresh.Create: - return refresh.EventIgnore - case refresh.Write: - if e.Path == "test/monitored/ignore.go" { - return refresh.EventBypass - } - return refresh.EventContinue - case refresh.Remove: - return refresh.EventContinue - // Other cases as needed ... - } - return refresh.EventContinue -} diff --git a/example/test/.gitignore b/example/test/.gitignore deleted file mode 100644 index cb69b78..0000000 --- a/example/test/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -dist/ -ignoreme/ - -.txt diff --git a/example/test/README.md b/example/test/README.md deleted file mode 100644 index 5915de5..0000000 --- a/example/test/README.md +++ /dev/null @@ -1,8 +0,0 @@ -## Example's project - -This is a simple project that main.go just prints a log ever second for 100 seconds. -Each folder is a different case of montiored or ignored or tests for wildcards open each one and change files -while refrencing the config in the example/ root to understand why certain things trigger reloads and others do not - - - diff --git a/example/test/app b/example/test/app deleted file mode 100755 index 5c10d96..0000000 Binary files a/example/test/app and /dev/null differ diff --git a/example/test/bin/app b/example/test/bin/app deleted file mode 100755 index 6818853..0000000 Binary files a/example/test/bin/app and /dev/null differ diff --git a/example/test/bin/myapp b/example/test/bin/myapp deleted file mode 100755 index 86a43c0..0000000 Binary files a/example/test/bin/myapp and /dev/null differ diff --git a/example/test/ignoreme/ignored.go b/example/test/ignoreme/ignored.go deleted file mode 100644 index fcfe869..0000000 --- a/example/test/ignoreme/ignored.go +++ /dev/null @@ -1,10 +0,0 @@ -package ignoreme - -import ( - "fmt" -) - -func Ignore() { - fmt.Println("This file is ignored") - -} diff --git a/example/test/ignoreme/ignoredFile.go b/example/test/ignoreme/ignoredFile.go deleted file mode 100644 index 26fc28d..0000000 --- a/example/test/ignoreme/ignoredFile.go +++ /dev/null @@ -1,10 +0,0 @@ -package ignoreme - -import ( - "fmt" -) - -func FullIgnore() { - fmt.Println("This file is ignored") -} - diff --git a/example/test/main.go b/example/test/main.go deleted file mode 100644 index bcf7dfd..0000000 --- a/example/test/main.go +++ /dev/null @@ -1,14 +0,0 @@ -package main - -import ( - "fmt" - "time" -) - -func main() { - for i := 0; i < 30; i++ { - time.Sleep(1 * time.Second) - fmt.Println("changed me", i) - time.Sleep(1 * time.Second) - } -} diff --git a/example/test/monitored/README.md b/example/test/monitored/README.md deleted file mode 100644 index cb70c16..0000000 --- a/example/test/monitored/README.md +++ /dev/null @@ -1,3 +0,0 @@ -## Monitored Folder -This folder is monitored according to the ruleset but does contain files and extensions that break the ruleset and will not trigger a reload - diff --git a/example/test/monitored/ignore.go b/example/test/monitored/ignore.go deleted file mode 100644 index 09c5995..0000000 --- a/example/test/monitored/ignore.go +++ /dev/null @@ -1,12 +0,0 @@ -package main - -import ( - "fmt" -) - -// This file while in a monitored folder with a monitored extension carries the same name as an ignore.go in the ignored files -// Changes to this file will be recognized in the debug logs but will not trigger a reload -func ignored() { - - fmt.Println("This file is ignored and not changed") -} diff --git a/example/test/monitored/ignore.txt b/example/test/monitored/ignore.txt deleted file mode 100644 index df1310b..0000000 --- a/example/test/monitored/ignore.txt +++ /dev/null @@ -1,3 +0,0 @@ -I am a file in a monitored folder -I do not trigger a reload due to my extension being .txt and not in the watched extensions - diff --git a/example/test/monitored/monitored.go b/example/test/monitored/monitored.go deleted file mode 100644 index a331224..0000000 --- a/example/test/monitored/monitored.go +++ /dev/null @@ -1,11 +0,0 @@ -package main - -import ( - "fmt" -) - -// I am a monitored file that has no exceptions and will trigger a reload -// Changeme to see -func monitor() { - fmt.Println("Changeme") -} diff --git a/example/test/nested/README.md b/example/test/nested/README.md deleted file mode 100644 index 63fdad1..0000000 --- a/example/test/nested/README.md +++ /dev/null @@ -1,4 +0,0 @@ -## Nested Folders - -This folder is used to showcase patternmatching -o diff --git a/example/test/nested/ig/file.go b/example/test/nested/ig/file.go deleted file mode 100644 index ef6932e..0000000 --- a/example/test/nested/ig/file.go +++ /dev/null @@ -1,5 +0,0 @@ - -ooo -o -:w -o diff --git a/example/test/nested/ig/file.txt b/example/test/nested/ig/file.txt deleted file mode 100644 index 62bcd97..0000000 --- a/example/test/nested/ig/file.txt +++ /dev/null @@ -1,2 +0,0 @@ -writing thingd - diff --git a/example/test/nested/ignore/file.txt b/example/test/nested/ignore/file.txt deleted file mode 100644 index 5097c88..0000000 --- a/example/test/nested/ignore/file.txt +++ /dev/null @@ -1,3 +0,0 @@ -adddwriting text - -o diff --git a/example/test/nested/ignore1/file.txt b/example/test/nested/ignore1/file.txt deleted file mode 100644 index 831dfe2..0000000 --- a/example/test/nested/ignore1/file.txt +++ /dev/null @@ -1,5 +0,0 @@ -Stuffs -// -o - - diff --git a/example/test/nested/ignore2/file.txt b/example/test/nested/ignore2/file.txt deleted file mode 100644 index 595147a..0000000 --- a/example/test/nested/ignore2/file.txt +++ /dev/null @@ -1,2 +0,0 @@ - -o/ diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..40968b5 --- /dev/null +++ b/examples/basic/README.md @@ -0,0 +1,23 @@ +# Basic example + +Embeds refresh as a library: watches this directory for `*.go` changes and, on +each change, rebuilds and restarts the small program in [`app/`](./app). + +## Run + +```sh +go run . +``` + +Then edit [`app/main.go`](./app/main.go) — refresh rebuilds `bin/app` and +restarts it. Press `Ctrl-C` to stop. + +## Layout + +| Path | Purpose | +| --------------- | -------------------------------------------------- | +| `main.go` | Configures and starts the engine (library usage). | +| `app/main.go` | The supervised long-running process. | +| `config.yaml` | The same configuration as a file (`NewEngineFromYAML`). | + +The built binary is written to `bin/` (gitignored). diff --git a/examples/basic/app/main.go b/examples/basic/app/main.go new file mode 100644 index 0000000..878b9c0 --- /dev/null +++ b/examples/basic/app/main.go @@ -0,0 +1,16 @@ +// Command app is the sample long-running process supervised by the basic +// example. Edit this file while the example is running to see refresh rebuild +// and restart it. +package main + +import ( + "fmt" + "time" +) + +func main() { + fmt.Println("app started — edit examples/basic/app/main.go to trigger a reload") + for { + time.Sleep(time.Second) + } +} diff --git a/examples/basic/config.yaml b/examples/basic/config.yaml index 31a0de2..a4e9e08 100644 --- a/examples/basic/config.yaml +++ b/examples/basic/config.yaml @@ -1,27 +1,21 @@ -# Sample configuration for refresh -root_path: "./src" -log_level: "debug" -debounce: 500 +# Equivalent of main.go expressed as a config file. +# Load it with engine.NewEngineFromYAML("config.yaml"). +# Note the top-level `config:` key, which NewEngineFromYAML expects. +config: + root_path: "." + log_level: "debug" + debounce: 500 -ignore: - dir: [".git", "node_modules", "vendor"] - file: [".gitignore", ".DS_Store"] - watched_extension: ["*.go", "*.js", "*.ts", "*.jsx", "*.tsx", "*.html", "*.css"] - git_ignore: true + ignore: + dir: [".git", "node_modules", "vendor", "bin"] + file: [".gitignore", ".DS_Store"] + watched_extension: ["*.go"] + git: true -# Define processes to run -executes: - - cmd: "go run main.go" - type: "primary" - change_dir: "./src" - - - cmd: "npm run watch" - type: "background" - change_dir: "./frontend" - - - cmd: "go generate ./..." - type: "once" - - - cmd: "go test ./..." - type: "blocking" - timeout: 30 \ No newline at end of file + # Executes run in order. type is one of: background | once | blocking | primary + executes: + - cmd: "go build -o ./bin/app ./app" + type: "blocking" + + - cmd: "./bin/app" + type: "primary" diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..c5fb629 --- /dev/null +++ b/examples/basic/main.go @@ -0,0 +1,44 @@ +// Command basic demonstrates embedding refresh as a library. It watches this +// directory for *.go changes and, on each change, rebuilds and restarts the +// small program under ./app. +// +// Run it from this directory: +// +// go run . +// +// Then edit app/main.go and watch it rebuild and restart. The same setup can be +// expressed in a config file instead — see config.yaml and NewEngineFromYAML. +package main + +import ( + "log" + + "github.com/atterpac/refresh/engine" +) + +func main() { + cfg := engine.Config{ + RootPath: ".", + LogLevel: "debug", + Debounce: 500, + Ignore: engine.Ignore{ + WatchedExten: []string{"*.go"}, + Dir: []string{".git", "node_modules", "vendor", "bin"}, + IgnoreGit: true, + }, + ExecStruct: []engine.Execute{ + // Build the watched app; blocking so the restart waits for a good binary. + {Cmd: "go build -o ./bin/app ./app", Type: engine.Blocking}, + // The long-running primary; killed and restarted on each reload. + {Cmd: "./bin/app", Type: engine.Primary}, + }, + } + + eng, err := engine.NewEngineFromConfig(cfg) + if err != nil { + log.Fatal(err) + } + if err := eng.Start(); err != nil { + log.Fatal(err) + } +} diff --git a/examples/kitchen-sink/README.md b/examples/kitchen-sink/README.md new file mode 100644 index 0000000..c6b968c --- /dev/null +++ b/examples/kitchen-sink/README.md @@ -0,0 +1,36 @@ +# Kitchen-sink example + +Exercises **every** execute type in a single config, plus a reload callback and +ignore rules. Doubles as the project's end-to-end integration test +([`main_test.go`](./main_test.go)). + +| Type | Demo command writes to | Behavior verified | +| ------------ | -------------------------- | ------------------------------------------ | +| `once` | `artifacts/once.log` | Runs exactly once, never re-runs on reload | +| `background` | `artifacts/background.log` | Starts once, survives reloads | +| `blocking` | `artifacts/blocking.log` | Re-runs every cycle, finishes before primary restarts | +| `primary` | `artifacts/primary.log` | Killed and restarted on every reload | + +## Run + +```sh +go run . +``` + +Then edit [`watched/trigger.go`](./watched/trigger.go) and save — the blocking +step re-runs and the primary restarts, while `once`/`background` stay put. The +reload callback logs each detected change. `Ctrl-C` stops everything cleanly. + +> Uses a POSIX shell (`sh`), so run on Linux or macOS. + +## As an integration test + +`main_test.go` builds the same config against a temp directory, starts the real +engine, triggers a filesystem reload, and asserts each type's semantics from the +`artifacts/` markers — then shuts down and confirms no processes leak: + +```sh +go test ./examples/kitchen-sink/ +``` + +Runtime output goes to `artifacts/` (gitignored). diff --git a/examples/kitchen-sink/main.go b/examples/kitchen-sink/main.go new file mode 100644 index 0000000..8df1774 --- /dev/null +++ b/examples/kitchen-sink/main.go @@ -0,0 +1,73 @@ +// Command kitchen-sink demonstrates every refresh execute type in one config: +// +// - once — a setup step that runs a single time at startup +// - background — a long-lived process that survives reloads +// - blocking — a build-style step that re-runs and must finish each cycle +// - primary — the main process, killed and restarted on every reload +// +// It also wires a reload Callback and ignore rules. Run it from this directory +// and edit watched/trigger.go to see a reload: +// +// go run . +// +// The demo commands use a POSIX shell, so run it on Linux or macOS. The same +// buildConfig is exercised by the integration test in main_test.go. +package main + +import ( + "log" + "log/slog" + + "github.com/atterpac/refresh/engine" +) + +// buildConfig assembles a Config exercising all execute types, rooted at root. +// Commands use paths relative to root — refresh runs each command with its +// working directory set accordingly, so no absolute paths are needed. Each step +// appends a line to a file under artifacts/ so its behavior is observable. +func buildConfig(root string) engine.Config { + return engine.Config{ + RootPath: root, + LogLevel: "info", + Debounce: 300, + // A standalone background service: started once, survives reloads, and is + // killed only on shutdown (e.g. `npm run dev`). + BackgroundStruct: engine.Execute{ + Cmd: "mkdir -p artifacts && echo up >> artifacts/bgstruct.log && sleep 3600", + }, + Ignore: engine.Ignore{ + WatchedExten: []string{"*.go"}, // only react to Go changes + Dir: []string{".git", "artifacts"}, // never react to our own output + File: []string{"*_ignore.go"}, + }, + ExecStruct: []engine.Execute{ + // Runs a single time at startup, before anything else. + {Cmd: "mkdir -p artifacts && echo setup >> artifacts/once.log", Type: engine.Once}, + // ChangeDir runs the command with its working directory set to sub/, + // so the relative marker lands there rather than at the root. + {Cmd: "echo here > marker.txt", ChangeDir: "sub", Type: engine.Once}, + // Started once, survives reloads, killed on shutdown. + {Cmd: "mkdir -p artifacts && echo up >> artifacts/background.log && sleep 3600", Type: engine.Background}, + // Re-runs every cycle and must finish before the primary restarts. + {Cmd: "mkdir -p artifacts && echo build >> artifacts/blocking.log", Type: engine.Blocking}, + // The long-lived process, killed and restarted on each reload. + {Cmd: "mkdir -p artifacts && echo run >> artifacts/primary.log && sleep 3600", Type: engine.Primary}, + }, + } +} + +func main() { + cfg := buildConfig(".") + cfg.Callback = func(e *engine.EventCallback) engine.EventHandle { + slog.Info("reload callback", "path", e.Path) + return engine.EventContinue + } + + eng, err := engine.NewEngineFromConfig(cfg) + if err != nil { + log.Fatal(err) + } + if err := eng.Start(); err != nil { + log.Fatal(err) + } +} diff --git a/examples/kitchen-sink/main_test.go b/examples/kitchen-sink/main_test.go new file mode 100644 index 0000000..c12435d --- /dev/null +++ b/examples/kitchen-sink/main_test.go @@ -0,0 +1,174 @@ +//go:build linux || darwin + +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/atterpac/refresh/engine" +) + +// waitFor polls cond until it is true or a generous deadline elapses, so the +// test tolerates filesystem-notification and process-startup latency without +// fixed sleeps. +func waitFor(cond func() bool) bool { + deadline := time.Now().Add(4 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(20 * time.Millisecond) + } + return cond() +} + +// lines counts the non-empty lines in a file, or 0 if it doesn't exist yet. +func lines(path string) int { + data, err := os.ReadFile(path) + if err != nil { + return 0 + } + n := 0 + for l := range strings.SplitSeq(string(data), "\n") { + if strings.TrimSpace(l) != "" { + n++ + } + } + return n +} + +// writeGo writes a .go file under watched/ with a changing constant so each call +// produces a content-modification event (reliable on both linux and darwin, +// unlike file creation). +func writeGo(t *testing.T, root, name string, version int) { + t.Helper() + src := fmt.Sprintf("package watched\n\nconst V = %d\n", version) + if err := os.WriteFile(filepath.Join(root, "watched", name), []byte(src), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestKitchenSinkIntegration drives the full engine lifecycle (Start, real +// filesystem-triggered reloads, then Stop) and verifies the contract of every +// execute type, ChangeDir, the reload callback (including veto), and the ignore +// rules. +func TestKitchenSinkIntegration(t *testing.T) { + root := t.TempDir() + mustMkdir(t, filepath.Join(root, "watched")) + mustMkdir(t, filepath.Join(root, "sub")) // ChangeDir target must exist + + // Pre-create the watched files so later writes are modifications. + writeGo(t, root, "trigger.go", 1) + writeGo(t, root, "bypass_me.go", 1) // callback will veto changes to this + writeGo(t, root, "thing_ignore.go", 1) // matches the *_ignore.go ignore rule + + var callbacks atomic.Int32 + cfg := buildConfig(root) + cfg.LogLevel = "mute" // keep test output clean + cfg.Callback = func(e *engine.EventCallback) engine.EventHandle { + callbacks.Add(1) + if strings.Contains(e.Path, "bypass_me") { + return engine.EventBypass + } + return engine.EventContinue + } + + eng, err := engine.NewEngineFromConfig(cfg) + if err != nil { + t.Fatal(err) + } + + // Start blocks until shutdown; run it in the background and ensure a clean + // stop (which also kills the background/primary sleeps — no leaks). + done := make(chan error, 1) + go func() { done <- eng.Start() }() + t.Cleanup(func() { + eng.Stop() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("engine did not shut down within timeout") + } + }) + + art := func(name string) string { return filepath.Join(root, "artifacts", name) } + + // --- Initial cycle: every type should have produced its marker. --- + if !waitFor(func() bool { return lines(art("primary.log")) == 1 }) { + t.Fatalf("primary did not start (primary.log = %d lines)", lines(art("primary.log"))) + } + for _, f := range []string{"once.log", "background.log", "bgstruct.log", "blocking.log"} { + if !waitFor(func() bool { return lines(art(f)) >= 1 }) { + t.Fatalf("%s was not written on startup", f) + } + } + + // --- #2 ChangeDir: the marker must land in sub/, not at the root. --- + if _, err := os.Stat(filepath.Join(root, "sub", "marker.txt")); err != nil { + t.Errorf("ChangeDir execute did not run in sub/: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "marker.txt")); err == nil { + t.Error("ChangeDir was ignored: marker.txt landed at the root") + } + + // Let the watcher settle before editing, so changes are observed. + time.Sleep(200 * time.Millisecond) + + // --- #1 Callback veto: a change the callback bypasses must NOT reload. --- + cbBefore := callbacks.Load() + writeGo(t, root, "bypass_me.go", 2) + time.Sleep(900 * time.Millisecond) // longer than debounce + restart + if got := lines(art("primary.log")); got != 1 { + t.Errorf("callback EventBypass did not prevent reload (primary.log = %d)", got) + } + if callbacks.Load() <= cbBefore { + t.Error("callback was not invoked for the bypassed change") + } + + // --- #4 Ignore rule: a change matching *_ignore.go must NOT reload. --- + cbBefore = callbacks.Load() + writeGo(t, root, "thing_ignore.go", 2) + time.Sleep(900 * time.Millisecond) + if got := lines(art("primary.log")); got != 1 { + t.Errorf("ignore rule did not prevent reload (primary.log = %d)", got) + } + if callbacks.Load() <= cbBefore { + t.Error("ignored change was never observed (test would pass trivially)") + } + + // --- A normal change must reload: primary restarts, blocking re-runs. --- + writeGo(t, root, "trigger.go", 2) + if !waitFor(func() bool { return lines(art("primary.log")) >= 2 }) { + t.Fatal("primary did not restart on a watched change") + } + if !waitFor(func() bool { return lines(art("blocking.log")) >= 2 }) { + t.Fatal("blocking step did not re-run on reload") + } + + // Allow any (erroneous) extra work to surface before checking run-once + // semantics held. + time.Sleep(300 * time.Millisecond) + + if got := lines(art("once.log")); got != 1 { + t.Errorf("once execute ran %d times, want exactly 1", got) + } + if got := lines(art("background.log")); got != 1 { + t.Errorf("background execute restarted (%d lines), want 1 (it should survive reloads)", got) + } + if got := lines(art("bgstruct.log")); got != 1 { + t.Errorf("BackgroundStruct restarted (%d lines), want 1 (it should run once and survive reloads)", got) + } +} + +func mustMkdir(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } +} diff --git a/examples/kitchen-sink/sub/.gitkeep b/examples/kitchen-sink/sub/.gitkeep new file mode 100644 index 0000000..c795cfa --- /dev/null +++ b/examples/kitchen-sink/sub/.gitkeep @@ -0,0 +1 @@ +# Keeps the sub/ directory present for the ChangeDir demo. diff --git a/examples/kitchen-sink/watched/trigger.go b/examples/kitchen-sink/watched/trigger.go new file mode 100644 index 0000000..c4088e2 --- /dev/null +++ b/examples/kitchen-sink/watched/trigger.go @@ -0,0 +1,6 @@ +// Package watched exists only as an edit target for the kitchen-sink demo. +// Change the value below (or anything in this file) and save to trigger a +// reload while the example is running. +package watched + +const Version = 1 diff --git a/go.mod b/go.mod index 0172d81..f2e5ad2 100644 --- a/go.mod +++ b/go.mod @@ -4,39 +4,9 @@ go 1.25.0 require ( github.com/BurntSushi/toml v1.6.0 - github.com/charmbracelet/bubbles v1.0.0 - github.com/charmbracelet/bubbletea v1.3.10 - github.com/charmbracelet/lipgloss v1.1.0 github.com/lmittmann/tint v1.1.3 github.com/rjeczalik/notify v0.9.3 gopkg.in/yaml.v2 v2.4.0 ) -require ( - github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/x/ansi v0.11.6 // indirect - github.com/charmbracelet/x/cellbuf v0.0.15 // indirect - github.com/charmbracelet/x/term v0.2.2 // indirect - github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect - github.com/containerd/console v1.0.5 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/lucasb-eyer/go-colorful v1.4.0 // indirect - github.com/mattn/go-isatty v0.0.21 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.23 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/term v0.42.0 // indirect - golang.org/x/text v0.36.0 // indirect -) +require golang.org/x/sys v0.43.0 // indirect diff --git a/go.sum b/go.sum index 507e49c..94bc770 100644 --- a/go.sum +++ b/go.sum @@ -1,108 +1,12 @@ -github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= -github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= -github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= -github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= -github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= -github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= -github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= -github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg= -github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= -github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= -github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= -github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= -github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= -github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= -github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= -github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= -github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= -github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= -github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/lmittmann/tint v1.0.3 h1:W5PHeA2D8bBJVvabNfQD/XW9HPLZK1XoPZH0cq8NouQ= -github.com/lmittmann/tint v1.0.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= -github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= -github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= -github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= -github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= -github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4UgRGKZA0lc= -github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f h1:MvTmaQdww/z0Q4wrYjDSCcZ78NoftLQyHBSLW/Cx79Y= -github.com/sahilm/fuzzy v0.1.1-0.20230530133925-c48e322e2a8f/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= -github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/main.go b/main.go deleted file mode 100644 index c0cac99..0000000 --- a/main.go +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import ( - "github.com/atterpac/refresh/tui" -) - -func main() { - tui.StartTui(false) -} diff --git a/process/execute.go b/process/execute.go index 729bd69..5236c34 100644 --- a/process/execute.go +++ b/process/execute.go @@ -1,10 +1,9 @@ package process import ( - "errors" + "context" "fmt" "os/exec" - "strings" ) type Execute struct { @@ -36,11 +35,19 @@ var KILL_STALE = Execute{ var REFRESH_EXEC = "REFRESH" var KILL_EXEC = "KILL_STALE" -// Takes a string and splits it on spaces to create a slice of strings +// generateExec builds a command run through the platform shell, so command +// strings may use quoting, pipes, &&, and redirection rather than being a bare +// argv split on spaces. func generateExec(cmd string) *exec.Cmd { - slice := strings.Split(cmd, " ") - cmdEx := exec.Command(slice[0], slice[1:]...) - return cmdEx + shell, args := shellInvocation(cmd) + return exec.Command(shell, args...) +} + +// generateExecContext is generateExec bound to a context, so the command is +// killed if the context is cancelled (used for blocking/once steps). +func generateExecContext(ctx context.Context, cmd string) *exec.Cmd { + shell, args := shellInvocation(cmd) + return exec.CommandContext(ctx, shell, args...) } func stringToExecuteType(typing string) (ExecuteType, error) { @@ -54,6 +61,6 @@ func stringToExecuteType(typing string) (ExecuteType, error) { case "primary": return Primary, nil default: - return "", errors.New(fmt.Sprintf("Execute type of %s, is invalid", typing)) + return "", fmt.Errorf("execute type of %q is invalid", typing) } } diff --git a/process/process.go b/process/process.go index 0b7b341..103a46c 100644 --- a/process/process.go +++ b/process/process.go @@ -1,161 +1,222 @@ package process import ( - "bufio" "context" "errors" - "fmt" - "io" "log/slog" "os" "os/exec" "path/filepath" - "sync" ) +// Process is a single configured command plus the runtime handles for its +// currently running instance (if any). type Process struct { - Exec string - Type ExecuteType - Dir string - logPipe io.ReadCloser - cmd *exec.Cmd - pid int - pgid int + Exec string + Type ExecuteType + Dir string + + cmd *exec.Cmd + cancel context.CancelFunc + done chan struct{} } +// ProcessManager supervises the configured processes. +// +// All lifecycle methods (Start, Reload, Shutdown) are expected to be driven from +// a single goroutine — the engine's supervisor loop guarantees this — so the +// per-process runtime fields need no additional locking. The only concurrent +// actor is each process's own wait goroutine, which never mutates those fields. type ProcessManager struct { Processes []*Process RootDir string - mu sync.RWMutex - Ctxs map[string]context.Context - Cancels map[string]context.CancelFunc - FirstRun bool + started bool } func NewProcessManager() *ProcessManager { - return &ProcessManager{ - Processes: make([]*Process, 0), - Ctxs: make(map[string]context.Context), - Cancels: make(map[string]context.CancelFunc), - FirstRun: true, - } + return &ProcessManager{Processes: make([]*Process, 0)} } -func (pm *ProcessManager) AddProcess(exec string, typing string, dir string) error { +func (pm *ProcessManager) AddProcess(exec, typing, dir string) 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}) return nil } +// GetExecutes returns the configured command strings in order. func (pm *ProcessManager) GetExecutes() []string { - pm.mu.RLock() - defer pm.mu.RUnlock() - - var execs []string + execs := make([]string, 0, len(pm.Processes)) for _, p := range pm.Processes { execs = append(execs, p.Exec) } return execs } +// SetRootDirectory resolves dir to an absolute path used as the base for every +// process working directory. It no longer changes the calling process's working +// directory; each command's directory is set on its exec.Cmd instead, which +// removes the global-state race the previous os.Chdir approach suffered from. func (pm *ProcessManager) SetRootDirectory(dir string) error { - // First, get the current working directory as a fallback - currentDir, err := os.Getwd() - if err != nil { - return errors.New("Unable to get current working directory") - } - - // If dir is empty, use the current directory if dir == "" { - pm.RootDir = currentDir + wd, err := os.Getwd() + if err != nil { + return errors.New("unable to determine working directory") + } + pm.RootDir = wd return nil } - - // Change to the requested directory - err = os.Chdir(dir) - if err != nil { - return fmt.Errorf("Unable to change to root directory %s: %w", dir, err) - } - - // Get the absolute path of the new working directory - absDir, err := os.Getwd() + abs, err := filepath.Abs(dir) if err != nil { - // If we can't get the absolute path, restore the original directory - os.Chdir(currentDir) - return errors.New("Unable to get absolute path of root directory") + return err } - - // Store the absolute path - pm.RootDir = absDir - slog.Debug("Set root directory", "dir", pm.RootDir) - + pm.RootDir = abs + slog.Debug("root directory set", "dir", pm.RootDir) return nil } -func (pm *ProcessManager) ChangeExecuteDirectory(dir string) error { - if dir == "" { - return nil +// resolveDir returns the absolute working directory for a process, joining +// relative directories onto RootDir. +func (pm *ProcessManager) resolveDir(dir string) string { + switch { + case dir == "": + return pm.RootDir + case filepath.IsAbs(dir): + return dir + default: + return filepath.Join(pm.RootDir, dir) } +} - targetDir := dir - // Check if the path is relative (doesn't start with / or drive letter) - if !filepath.IsAbs(dir) { - // Combine with the root directory - targetDir = filepath.Join(pm.RootDir, dir) +// Start performs the initial pass over all configured processes: background and +// once processes run only here; blocking and primary processes run every cycle. +func (pm *ProcessManager) Start(ctx context.Context) error { + if len(pm.Processes) == 0 { + return errors.New("no processes configured") } + return pm.runCycle(ctx, true) +} - slog.Debug("Changing directory", "to", targetDir) - err := os.Chdir(targetDir) - if err != nil { - return fmt.Errorf("Unable to change execute directory: %s: %w", targetDir, err) - } - return nil +// Reload re-runs blocking steps and restarts the primary process. Background and +// once processes started during Start are left running. +func (pm *ProcessManager) Reload(ctx context.Context) error { + return pm.runCycle(ctx, false) } -func (pm *ProcessManager) RestoreRootDirectory() error { - if pm.RootDir == "" { - return nil - } - slog.Debug("Restoring directory to root", "dir", pm.RootDir) - err := os.Chdir(pm.RootDir) - if err != nil { - return fmt.Errorf("Unable to restore root directory: %s: %w", pm.RootDir, err) +func (pm *ProcessManager) runCycle(ctx context.Context, firstRun bool) error { + for _, p := range pm.Processes { + // Markers used by the ExecList config form; no-ops in the struct form. + if p.Exec == KILL_EXEC || p.Exec == REFRESH_EXEC { + continue + } + switch p.Type { + case Background: + if !firstRun { + continue + } + if err := pm.startAsync(ctx, p); err != nil { + slog.Error("starting background process", "exec", p.Exec, "err", err) + return err + } + case Once: + if !firstRun { + continue + } + if err := pm.runBlocking(ctx, p); err != nil { + slog.Error("once process failed", "exec", p.Exec, "err", err) + return err + } + case Blocking: + if err := pm.runBlocking(ctx, p); err != nil { + // On reload a failed blocking step (typically a build error) + // aborts the cycle and leaves the current primary running, so a + // broken build doesn't take down the last good process. + slog.Error("blocking process failed", "exec", p.Exec, "err", err) + return err + } + case Primary: + pm.stopProcess(p) // kill the previous instance (no-op on first run) + if err := pm.startAsync(ctx, p); err != nil { + slog.Error("starting primary process", "exec", p.Exec, "err", err) + return err + } + } } + pm.started = true return nil } -func printSubProcess(ctx context.Context, pipe io.ReadCloser) { - scanner := bufio.NewScanner(pipe) +// 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 +// can be signalled, not just the direct child. +func (pm *ProcessManager) startAsync(ctx context.Context, p *Process) error { + procCtx, cancel := context.WithCancel(ctx) + cmd := generateExec(p.Exec) + cmd.Dir = pm.resolveDir(p.Dir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + setProcessGroup(cmd) + + slog.Debug("starting process", "exec", p.Exec, "dir", cmd.Dir) + if err := cmd.Start(); err != nil { + cancel() + return err + } + done := make(chan struct{}) + p.cmd = cmd + p.cancel = cancel + p.done = done go func() { defer close(done) - for scanner.Scan() { - select { - case <-ctx.Done(): - return - default: - fmt.Println(scanner.Text()) + waitErr := make(chan error, 1) + go func() { waitErr <- cmd.Wait() }() + select { + case <-procCtx.Done(): + if err := killProcessTree(cmd); err != nil { + slog.Debug("killing process tree", "exec", p.Exec, "err", err) + } + <-waitErr // reap the process after the kill + case err := <-waitErr: + if err != nil { + slog.Debug("process exited", "exec", p.Exec, "err", err) } } }() + return nil +} - select { - case <-ctx.Done(): - // Context was canceled, try to close the pipe - pipe.Close() - case <-done: - // Scanner finished naturally +// runBlocking runs a process to completion. The command is bound to ctx so a +// shutdown while it is running terminates it. +func (pm *ProcessManager) runBlocking(ctx context.Context, p *Process) error { + cmd := generateExecContext(ctx, p.Exec) + cmd.Dir = pm.resolveDir(p.Dir) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + slog.Debug("running blocking process", "exec", p.Exec, "dir", cmd.Dir) + return cmd.Run() +} + +// stopProcess cancels a tracked process and waits for it to fully terminate. +// Safe to call on a process that isn't running. +func (pm *ProcessManager) stopProcess(p *Process) { + if p.cancel != nil { + p.cancel() + } + if p.done != nil { + <-p.done } + p.cmd, p.cancel, p.done = nil, nil, nil +} - if err := scanner.Err(); err != nil && err != io.EOF && !errors.Is(err, os.ErrClosed) { - slog.Debug("Scanner error", "err", err) +// Shutdown terminates every running process and waits for them to exit. +func (pm *ProcessManager) Shutdown() { + slog.Debug("shutting down processes") + for _, p := range pm.Processes { + pm.stopProcess(p) } } diff --git a/process/process_test.go b/process/process_test.go new file mode 100644 index 0000000..6af56e5 --- /dev/null +++ b/process/process_test.go @@ -0,0 +1,224 @@ +//go:build linux || darwin + +package process + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + "time" +) + +// alive reports whether a pid refers to a live process (signal 0 probes without +// actually sending anything). +func alive(pid int) bool { + if pid <= 0 { + return false + } + return syscall.Kill(pid, 0) == nil +} + +// waitFor polls until cond is true or the deadline elapses. +func waitFor(cond func() bool) bool { + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(10 * time.Millisecond) + } + return cond() +} + +func TestStartReloadShutdownLifecycle(t *testing.T) { + root := t.TempDir() + pm := NewProcessManager() + if err := pm.SetRootDirectory(root); err != nil { + t.Fatalf("SetRootDirectory: %v", err) + } + + // A blocking step that must re-run on every cycle, and a long-lived primary + // that must be killed and restarted on reload. + if err := pm.AddProcess("touch marker", "blocking", ""); err != nil { + t.Fatal(err) + } + if err := pm.AddProcess("sleep 30", "primary", ""); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := pm.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + + marker := filepath.Join(root, "marker") + if _, err := os.Stat(marker); err != nil { + t.Fatalf("blocking step did not run on start: %v", err) + } + + primary := pm.Processes[1] + pid1 := primary.cmd.Process.Pid + if !alive(pid1) { + t.Fatalf("primary not running after start (pid %d)", pid1) + } + + // Reload should re-run the blocking step and restart the primary with a new pid. + os.Remove(marker) + if err := pm.Reload(ctx); err != nil { + t.Fatalf("Reload: %v", err) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("blocking step did not re-run on reload: %v", err) + } + + pid2 := primary.cmd.Process.Pid + if pid2 == pid1 { + t.Fatalf("primary was not restarted (pid unchanged: %d)", pid1) + } + if !waitFor(func() bool { return !alive(pid1) }) { + t.Errorf("old primary (pid %d) still alive after reload", pid1) + } + if !alive(pid2) { + t.Fatalf("new primary (pid %d) not running after reload", pid2) + } + + // Shutdown must terminate the running primary. + pm.Shutdown() + if !waitFor(func() bool { return !alive(pid2) }) { + t.Errorf("primary (pid %d) still alive after shutdown", pid2) + } +} + +func TestShellFeaturesAreSupported(t *testing.T) { + root := t.TempDir() + pm := NewProcessManager() + if err := pm.SetRootDirectory(root); err != nil { + t.Fatal(err) + } + // Redirection and && only work when the command runs through a shell rather + // than a bare argv split. + if err := pm.AddProcess("echo one > out.txt && echo two >> out.txt", "blocking", ""); err != nil { + t.Fatal(err) + } + if err := pm.AddProcess("sleep 30", "primary", ""); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := pm.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + defer pm.Shutdown() + + data, err := os.ReadFile(filepath.Join(root, "out.txt")) + if err != nil { + t.Fatalf("shell command did not produce output file: %v", err) + } + if got := string(data); got != "one\ntwo\n" { + t.Errorf("shell features not honored, out.txt = %q", got) + } +} + +func TestKillProcessTreeNilSafe(t *testing.T) { + if err := killProcessTree(nil); err != nil { + t.Errorf("killProcessTree(nil) = %v, want nil", err) + } + if err := killProcessTree(&exec.Cmd{}); err != nil { + t.Errorf("killProcessTree on unstarted cmd = %v, want nil", err) + } +} + +func TestSetRootDirectoryDefaultsToCwd(t *testing.T) { + pm := NewProcessManager() + if err := pm.SetRootDirectory(""); err != nil { + t.Fatalf("SetRootDirectory(\"\"): %v", err) + } + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if pm.RootDir != wd { + t.Errorf("RootDir = %q, want cwd %q", pm.RootDir, wd) + } +} + +func TestStartWithNoProcessesErrors(t *testing.T) { + pm := NewProcessManager() + if err := pm.Start(context.Background()); err == nil { + t.Fatal("expected error when starting with no processes") + } +} + +func TestOnceFailureAbortsStartup(t *testing.T) { + pm := NewProcessManager() + if err := pm.SetRootDirectory(t.TempDir()); err != nil { + t.Fatal(err) + } + if err := pm.AddProcess("false", "once", ""); err != nil { + t.Fatal(err) + } + if err := pm.AddProcess("sleep 30", "primary", ""); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := pm.Start(ctx); err == nil { + t.Fatal("expected Start to fail when a once step exits non-zero") + } + if pm.Processes[1].cmd != nil { + t.Error("primary should not have started after a once failure") + } + pm.Shutdown() +} + +func TestStartAsyncFailsOnMissingDirectory(t *testing.T) { + pm := NewProcessManager() + if err := pm.SetRootDirectory(t.TempDir()); err != nil { + t.Fatal(err) + } + // A ChangeDir that does not exist makes exec.Cmd.Start fail, exercising the + // startAsync error path. + if err := pm.AddProcess("sleep 30", "primary", "does-not-exist"); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := pm.Start(ctx); err == nil { + t.Fatal("expected Start to fail when the working directory is missing") + } + pm.Shutdown() +} + +func TestBlockingFailureAbortsCycle(t *testing.T) { + root := t.TempDir() + pm := NewProcessManager() + if err := pm.SetRootDirectory(root); err != nil { + t.Fatal(err) + } + // A blocking step that exits non-zero must abort the cycle before the primary. + if err := pm.AddProcess("false", "blocking", ""); err != nil { + t.Fatal(err) + } + if err := pm.AddProcess("sleep 30", "primary", ""); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := pm.Start(ctx); err == nil { + t.Fatal("expected Start to fail when a blocking step fails") + } + if primary := pm.Processes[1]; primary.cmd != nil { + t.Error("primary should not have started after blocking failure") + } + pm.Shutdown() +} diff --git a/process/process_unix.go b/process/process_unix.go index 48d924b..a3f2bf7 100644 --- a/process/process_unix.go +++ b/process/process_unix.go @@ -3,209 +3,30 @@ package process import ( - "context" - "log/slog" - "os" - "path/filepath" + "os/exec" "syscall" - "time" ) -func (pm *ProcessManager) StartProcess(ctx context.Context, cancel context.CancelFunc) { - pm.mu.Lock() - defer pm.mu.Unlock() - - // Store the original directory to ensure we restore it at the end of function - originalDir, err := os.Getwd() - if err != nil { - slog.Error("Failed to get current working directory", "err", err) - // If we can't get the current directory, use our saved RootDir - originalDir = pm.RootDir - } - - // Ensure we always restore the original directory when this function exits - defer func() { - err := os.Chdir(originalDir) - if err != nil { - slog.Error("Failed to restore original directory", "dir", originalDir, "err", err) - } - }() - - if len(pm.Processes) == 0 { - slog.Warn("No Processes to Start") - os.Exit(1) - return - } - for _, p := range pm.Processes { - slog.Debug("Starting Process", "exec", p.Exec) - if p.Exec == "KILL_STALE" { - continue - } - if !pm.FirstRun && p.Type == Background { - continue - } - cmd := generateExec(p.Exec) - p.cmd = cmd - if p.Type == Primary { - // Ensure previous processes are killed if this isnt the first run - if !pm.FirstRun { - for _, pr := range pm.Processes { - if pr.Type != Background { - // check if pid is running - if pr.pid != 0 { - _, err := os.FindProcess(pr.pid) - if err != nil { - // slog.Debug("Process not running", "exec", pr.Exec) - continue - } - } - // Remove contexts - delete(pm.Ctxs, pr.Exec) - delete(pm.Cancels, pr.Exec) - // Wait for the process to terminate - select { - case <-ctx.Done(): - slog.Debug("Process terminated", "exec", pr.Exec) - case <-time.After(100 * time.Millisecond): - slog.Debug("Process not terminated... forcefully killing", "exec", pr.Exec) - } - // Kill any remaining child processes - if pr.pgid != 0 { - // slog.Debug("Killing process group", "pgid", pr.pgid) - syscall.Kill(-pr.pgid, syscall.SIGKILL) - } - } - } - time.Sleep(200 * time.Millisecond) - } else { - pm.FirstRun = false - } - } - var err error - if p.Type == Blocking || p.Type == Once { - if !pm.FirstRun && p.Type == Once { - continue - } - cmd.Stderr = os.Stderr - p.logPipe, err = cmd.StdoutPipe() - if err != nil { - slog.Error("Getting Stdout Pipe", "exec", p.Exec, "err", err) - } - go printSubProcess(ctx, p.logPipe) - - // Change to the command's directory if specified - if p.Dir != "" { - targetDir := p.Dir - if !filepath.IsAbs(p.Dir) { - // If relative path, make it relative to RootDir - targetDir = filepath.Join(pm.RootDir, p.Dir) - } - currentDir, _ := os.Getwd() - slog.Debug("Changing directory for process", "from", currentDir, "to", targetDir, "process", p.Exec) - err = os.Chdir(targetDir) - if err != nil { - slog.Error("Failed to change directory", "dir", targetDir, "err", err) - cancel() - return - } - } - - err = cmd.Run() - if err != nil { - slog.Error("Running Command", "exec", p.Exec, "err", err) - cancel() - return - } - slog.Debug("Process completed closing context", "exec", p.Exec) - ctx.Done() - } else { - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Stderr = os.Stderr - p.logPipe, err = cmd.StdoutPipe() - if err != nil { - slog.Error("Getting Stdout Pipe", "exec", p.Exec, "err", err) - } - go printSubProcess(ctx, p.logPipe) - - // Change to the command's directory if specified - if p.Dir != "" { - targetDir := p.Dir - if !filepath.IsAbs(p.Dir) { - // If relative path, make it relative to RootDir - targetDir = filepath.Join(pm.RootDir, p.Dir) - } - currentDir, _ := os.Getwd() - slog.Debug("Changing directory for process", "from", currentDir, "to", targetDir, "process", p.Exec) - err = os.Chdir(targetDir) - if err != nil { - slog.Error("Failed to change directory", "dir", targetDir, "err", err) - cancel() - continue - } - } - - err = cmd.Start() - if cmd.Process == nil { - slog.Error("Primary process not running", "exec", p.Exec) - cancel() - continue - } - - p.pgid, _ = syscall.Getpgid(cmd.Process.Pid) - p.pid = cmd.Process.Pid - - processCtx, processCancel := context.WithCancel(ctx) - pm.Ctxs[p.Exec] = processCtx - pm.Cancels[p.Exec] = processCancel - // slog.Debug("Stored Process Context", "exec", p.Exec) - - go func() { - errCh := make(chan error, 1) - go func() { - errCh <- cmd.Wait() - }() - select { - case <-processCtx.Done(): - _ = syscall.Kill(-p.pid, syscall.SIGKILL) - case <-ctx.Done(): - slog.Debug("Context closed", "exec", p.Exec) - _ = syscall.Kill(-p.pid, syscall.SIGKILL) - case err := <-errCh: - if err != nil { - cancel() - } - slog.Debug("Process Errored closing context", "exec", p.Exec) - ctx.Done() - delete(pm.Ctxs, p.Exec) - delete(pm.Cancels, p.Exec) - } - }() - } - if err != nil { - slog.Error("Running Command", "exec", p.Exec, "err", err) - cancel() - } +// shellInvocation returns the shell and arguments used to run a command string, +// so commands may use shell features (quoting, pipes, &&, redirection). +func shellInvocation(command string) (string, []string) { + return "/bin/sh", []string{"-c", command} +} - // After each process, restore to the original directory - err = os.Chdir(originalDir) - if err != nil { - slog.Error("Failed to restore directory after process", "dir", originalDir, "err", err) - } - } - pm.FirstRun = false +// setProcessGroup puts the command in its own process group so the entire tree +// (the child and anything it spawns) can be signalled together. +func setProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} } -func (pm *ProcessManager) KillProcesses() { - for _, p := range pm.Processes { - if p.pid != 0 { - _, err := os.FindProcess(p.pid) - if err != nil { - continue - } - syscall.Kill(-p.pid, syscall.SIGKILL) - if cancel, ok := pm.Cancels[p.Exec]; ok { - cancel() - } - } +// killProcessTree force-kills the command's whole process group, falling back to +// the direct process if the group id can't be resolved. +func killProcessTree(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + if pgid, err := syscall.Getpgid(cmd.Process.Pid); err == nil { + return syscall.Kill(-pgid, syscall.SIGKILL) } + return cmd.Process.Kill() } diff --git a/process/process_unsupported.go b/process/process_unsupported.go new file mode 100644 index 0000000..15ab661 --- /dev/null +++ b/process/process_unsupported.go @@ -0,0 +1,21 @@ +//go:build !linux && !darwin && !windows + +package process + +import "os/exec" + +// shellInvocation falls back to /bin/sh on platforms without a known shell. +func shellInvocation(command string) (string, []string) { + return "/bin/sh", []string{"-c", command} +} + +// setProcessGroup is a no-op on platforms without process-group support. +func setProcessGroup(cmd *exec.Cmd) {} + +// killProcessTree falls back to killing only the direct process. +func killProcessTree(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + return cmd.Process.Kill() +} diff --git a/process/process_windows.go b/process/process_windows.go index 570f255..9b82e86 100644 --- a/process/process_windows.go +++ b/process/process_windows.go @@ -3,207 +3,24 @@ package process import ( - "context" - "log/slog" - "os" "os/exec" - "path/filepath" "strconv" - "time" ) -func (pm *ProcessManager) StartProcess(ctx context.Context, cancel context.CancelFunc) { - pm.mu.Lock() - defer pm.mu.Unlock() - - // Store the original directory to ensure we restore it at the end of function - originalDir, err := os.Getwd() - if err != nil { - slog.Error("Failed to get current working directory", "err", err) - // If we can't get the current directory, use our saved RootDir - originalDir = pm.RootDir - } - - // Ensure we always restore the original directory when this function exits - defer func() { - err := os.Chdir(originalDir) - if err != nil { - slog.Error("Failed to restore original directory", "dir", originalDir, "err", err) - } - }() - - if len(pm.Processes) == 0 { - // slog.Warn("No Processes to Start") - return - } - for _, p := range pm.Processes { - if p.Exec == "KILL_STALE" { - continue - } - if !pm.FirstRun && p.Type == Background { - continue - } - - cmd := generateExec(p.Exec) - p.cmd = cmd - - if p.Type == Primary { - if !pm.FirstRun { - for _, pr := range pm.Processes { - if pr.Type != Background { - // check if pid is running - if pr.pid != 0 { - if _, err := os.FindProcess(pr.pid); err == nil { - if cancel, exists := pm.Cancels[pr.Exec]; exists { - cancel() - delete(pm.Ctxs, pr.Exec) - delete(pm.Cancels, pr.Exec) - } - - time.Sleep(100 * time.Millisecond) - - if err := taskKill(pr.pid); err != nil { - slog.Debug("Failed to kill process", "exec", pr.Exec, "pid", pr.pid, "err", err) - } - } - } - } - } - // slog.Debug("Processes killed") - time.Sleep(200 * time.Millisecond) - } else { - // slog.Debug("First run, not killing processes") - pm.FirstRun = false - } - // Log buffers - } - - // Change to the command's directory if specified - if p.Dir != "" { - targetDir := p.Dir - if !filepath.IsAbs(p.Dir) { - // If relative path, make it relative to RootDir - targetDir = filepath.Join(pm.RootDir, p.Dir) - } - currentDir, _ := os.Getwd() - slog.Debug("Changing directory for process", "from", currentDir, "to", targetDir, "process", p.Exec) - err = os.Chdir(targetDir) - if err != nil { - slog.Error("Failed to change directory", "dir", targetDir, "err", err) - cancel() - continue - } - } - - var err error - if p.Type == Blocking || p.Type == Once { - if p.Type == Once && !pm.FirstRun { - continue - } - cmd.Stderr = os.Stderr - p.logPipe, err = cmd.StdoutPipe() - if err != nil { - slog.Error("Getting stdout pipe", "exec", p.Exec, "err", err) - cancel() - continue - } - - subProcessCtx, subProcessCancel := context.WithCancel(ctx) - go printSubProcess(subProcessCtx, p.logPipe) - - err = cmd.Run() - subProcessCancel() - - if err != nil { - slog.Error("Running Command", "exec", p.Exec, "err", err) - cancel() - continue - } - } else { - cmd.Stderr = os.Stderr - p.logPipe, err = cmd.StdoutPipe() - if err != nil { - slog.Error("Getting Stdout Pipe", "exec", p.Exec, "err", err) - cancel() - continue - } - - err = cmd.Start() - if err != nil { - slog.Error("Starting command", "exec", p.Exec, "err", err) - cancel() - continue - } - - if cmd.Process != nil { - p.pid = cmd.Process.Pid - processCtx, processCancel := context.WithCancel(ctx) - pm.Ctxs[p.Exec] = processCtx - pm.Cancels[p.Exec] = processCancel - - subProcessCtx, subProcessCancel := context.WithCancel(processCtx) - go printSubProcess(subProcessCtx, p.logPipe) - - go func(exec string, pid int, subCancel context.CancelFunc) { - defer subCancel() - - select { - case <-processCtx.Done(): - if err := taskKill(pid); err != nil { - slog.Debug("Failed to kill process after context done", "exec", exec, "pid", pid, "err", err) - } - case <-ctx.Done(): - if err := taskKill(pid); err != nil { - slog.Debug("Failed to kill process after parent context done", "exec", exec, "pid", pid, "err", err) - } - default: - err := cmd.Wait() - if err != nil { - slog.Error("Process exited with error", "exec", exec, "err", err) - cancel() - } - - pm.mu.Lock() - delete(pm.Ctxs, exec) - delete(pm.Cancels, exec) - pm.mu.Unlock() - } - }(p.Exec, p.pid, subProcessCancel) - } else { - slog.Error("Process did not start properly", "exec", p.Exec) - cancel() - continue - } - } - - // After each process, restore to the original directory - err = os.Chdir(originalDir) - if err != nil { - slog.Error("Failed to restore directory after process", "dir", originalDir, "err", err) - } - } - - pm.FirstRun = false +// shellInvocation returns the shell and arguments used to run a command string, +// so commands may use shell features. Windows uses cmd.exe. +func shellInvocation(command string) (string, []string) { + return "cmd", []string{"/C", command} } -// Window specific kill process -func (pm *ProcessManager) KillProcesses() { - // slog.Debug("Killing Processes") - for _, p := range pm.Processes { - err := taskKill(p.pid) - if err != nil { - // slog.Error("Error killing process", "pid", p.cmd.Process.Pid, "err", err.Error()) - } - } -} +// setProcessGroup is a no-op on Windows; process-tree termination is handled by +// taskkill /T in killProcessTree. +func setProcessGroup(cmd *exec.Cmd) {} -func taskKill(pid int) error { - kill := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)) - err := kill.Run() - if err != nil { - // slog.Error("Error killing process", "pid", pid, "err", err.Error()) - return err +// killProcessTree force-kills the command and all of its child processes. +func killProcessTree(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil } - // slog.Debug("Process successfull killed", "pid", pid) - return nil + return exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)).Run() } diff --git a/tui/tui.go b/tui/tui.go deleted file mode 100644 index cd759c2..0000000 --- a/tui/tui.go +++ /dev/null @@ -1,79 +0,0 @@ -package tui - -import ( - "fmt" - "log/slog" - "os" - - "github.com/atterpac/refresh/engine" - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -func StartTui(withTui bool) { - engine, err := engine.NewEngineFromTOML("./example/example.toml") - if err != nil { - panic(err) - } - if !withTui { - err := engine.Start() - if err != nil { - slog.Error("Refresh has exited", "err", err) - os.Exit(0) - } - } - executes := engine.ProcessManager.GetExecutes() - println("count", len(executes)) - items := make([]list.Item, len(executes)) - for _, execute := range executes { - items = append(items, item{title: execute, desc: "Description"}) - } - m := model{list: list.New(items, list.NewDefaultDelegate(), 0, 5)} - m.list.Title = "Dev Mode Active" - - p := tea.NewProgram(m, tea.WithAltScreen()) - - if _, err := p.Run(); err != nil { - fmt.Println("Error running program:", err) - os.Exit(1) - } -} - -var docStyle = lipgloss.NewStyle().Margin(1, 2) - -type item struct { - title, desc string -} - -func (i item) Title() string { return i.title } -func (i item) Description() string { return i.desc } -func (i item) FilterValue() string { return i.title } - -type model struct { - list list.Model -} - -func (m model) Init() tea.Cmd { - return nil -} - -func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyMsg: - if msg.String() == "ctrl+c" { - return m, tea.Quit - } - case tea.WindowSizeMsg: - h, v := docStyle.GetFrameSize() - m.list.SetSize(msg.Width-h, msg.Height-v) - } - - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - return m, cmd -} - -func (m model) View() string { - return docStyle.Render(m.list.View()) -}