├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── LICENSE ├── README.md ├── arelo.go ├── arelo_test.go ├── arelo_unix.go ├── arelo_unix_test.go ├── arelo_windows.go ├── fspoll ├── fsnotify.go ├── fspoll.go ├── fspoll_test.go └── poller.go ├── go.mod └── go.sum /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | jobs: 7 | goreleaser: 8 | name: runner / goreleaser 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Go 16 | uses: actions/setup-go@v4 17 | with: 18 | go-version-file: "go.mod" 19 | cache: true 20 | cache-dependency-path: "go.sum" 21 | - name: Run GoReleaser 22 | uses: goreleaser/goreleaser-action@v5 23 | with: 24 | distribution: goreleaser 25 | version: latest 26 | args: release --clean 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version-file: "go.mod" 20 | cache: true 21 | cache-dependency-path: "go.sum" 22 | 23 | - name: Test 24 | run: go test -v -race -shuffle=on ./... 25 | 26 | - name: Build (Windows) 27 | run: GOOS=windows go build 28 | 29 | - name: Build (macOS) 30 | run: GOOS=darwin go build 31 | 32 | - name: Build (Linux) 33 | run: GOOS=linux go build 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | project_name: arelo 2 | env: 3 | - GO111MODULE=on 4 | before: 5 | hooks: 6 | - go mod tidy 7 | builds: 8 | - id: arelo 9 | binary: arelo 10 | ldflags: 11 | # - -s -w 12 | # - -X main.Version={{.Version}} 13 | # - -X main.Revision={{.ShortCommit}} 14 | env: 15 | - CGO_ENABLED=0 16 | goos: 17 | - darwin 18 | - linux 19 | - windows 20 | goarch: 21 | - amd64 22 | - arm64 23 | archives: 24 | - name_template: >- 25 | {{- .ProjectName }}_ 26 | {{- title .Os }}_ 27 | {{- if eq .Arch "amd64" }}x86_64 28 | {{- else if eq .Arch "386" }}i386 29 | {{- else }}{{ .Arch }}{{ end }} 30 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 31 | format_overrides: 32 | - goos: windows 33 | format: zip 34 | release: 35 | prerelease: auto 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 MakKi (makki_d) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Arelo - a simple auto reload utility 2 | 3 | [![go test](https://github.com/makiuchi-d/arelo/actions/workflows/test.yml/badge.svg)](https://github.com/makiuchi-d/arelo/actions/workflows/test.yml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/makiuchi-d/arelo)](https://goreportcard.com/report/github.com/makiuchi-d/arelo) 5 | 6 | Arelo executes the specified command and monitors the files under the target directory. 7 | When the file that matches the pattern has been modified, restart the command. 8 | 9 | ## Features 10 | 11 | - Simple command line interface without config file 12 | - Monitoring file patterns are specified as glob 13 | - globstar (**; matches to zero or more directories) supported 14 | - can match the no extention filename 15 | - can match the hidden filename which starts with "." 16 | - Safely terminate child processes 17 | - Any command line tool can be executed 18 | - not only go project 19 | - can execute shell script 20 | - No unnesesary servers 21 | - no need to use local port unlike http server 22 | 23 | ## Install 24 | 25 | ``` 26 | go install github.com/makiuchi-d/arelo@latest 27 | ``` 28 | 29 | Alternatively, you can install it via [Homebrew](https://formulae.brew.sh/formula/arelo): 30 | 31 | ``` 32 | brew install arelo 33 | ``` 34 | 35 | Or, you can download the executable binaries from the [release page](https://github.com/makiuchi-d/arelo/releases). 36 | 37 | To get a static-linked executable binary, build with `CGO_ENABLED=0`. 38 | 39 | ## Quick start 40 | 41 | Run this command in your Go project directory. 42 | 43 | ``` 44 | arelo -p '**/*.go' -i '**/.*' -i '**/*_test.go' -- go run . 45 | ``` 46 | 47 | ## Usage 48 | 49 | ``` 50 | Usage: arelo [OPTION]... -- COMMAND 51 | Run the COMMAND and restart when a file matches the pattern has been modified. 52 | 53 | Options: 54 | -d, --delay duration duration to delay the restart of the command (default 1s) 55 | -f, --filter event filter file system event (CREATE|WRITE|REMOVE|RENAME|CHMOD) 56 | -h, --help display this message 57 | -i, --ignore glob ignore pathname glob pattern 58 | -n, --no-stdin do not forward stdin to the command 59 | -p, --pattern glob trigger pathname glob pattern (default "**") 60 | --polling interval poll files at given interval instead of using fsnotify 61 | -r, --restart restart the command on exit 62 | -s, --signal signal signal used to stop the command (default "SIGTERM") 63 | -t, --target path observation target path (default "./") 64 | -v, --verbose verbose output 65 | -V, --version display version 66 | ``` 67 | 68 | ### Options 69 | 70 | #### -t, --target path 71 | 72 | Monitor file modifications under the `path` directory. 73 | The subdirectories are also monitored unless they match to the ignore patterns. 74 | 75 | This option can be set multiple times. 76 | 77 | The default value is the current directory ("./"). 78 | 79 | Note: 80 | This option can be file instead of directory, 81 | but arelo cannot follow modification after the file has been removed/renamed. 82 | 83 | #### -p, --pattern glob 84 | 85 | Restart command when the modified file is matched to this pattern. 86 | 87 | The pattern is specified as an extended glob 88 | that supports `{alt1,...}`, `**` like zsh or bash with globstar option. 89 | And note that the path delimiter is `/` even on Windows. 90 | 91 | This option can set multiple times. 92 | 93 | The default value ("**") is a pattern that matches any file in the target directories and their subdirectories. 94 | 95 | #### -i, --ignore glob 96 | 97 | Ignore the file or directory whose names is matched to this pattern. 98 | 99 | This option takes precedence over the --pattern option. 100 | 101 | This option can set multiple times. 102 | 103 | 104 | #### -f, --filter event 105 | 106 | Filter the filesystem event to ignore it. 107 | 108 | The event can be `CREATE`, `WRITE`, `REMOVE`, `RENAME` or `CHMOD`. 109 | 110 | This option can set multiple times. 111 | 112 | #### -d, --delay duration 113 | 114 | Delay the restart of the command from the detection of the pattern matched file modification. 115 | The detections within the delay are ignored. 116 | 117 | The duration is specified as a number with a unit suffix ("ns", "us" (or "µs"), "ms", "s", "m", "h"). 118 | 119 | #### -s, --signal signal 120 | 121 | This signal will be sent to stop the command on restart. 122 | The default signal is `SIGTERM`. 123 | 124 | This option can be `SIGHUP`, `SIGINT`, `SIGQUIT`, `SIGKILL`, `SIGUSR1`, `SIGUSR2`, `SIGWINCH` or `SIGTERM`. 125 | 126 | This option is not available on Windows. 127 | 128 | #### -r, --restart 129 | 130 | Automatically restart the command when it exits, similar to when the pattern matched file is modified. 131 | 132 | #### --polling interval 133 | 134 | Poll files at the specified interval instead of using fsnotify. 135 | If not set or set to `0`, fsnotify is used for file monitoring. 136 | 137 | This option is useful when fsnotify cannot detect changes, such as on WSL2. 138 | 139 | The interval is specified as a number with a unit suffix ("ns", "us" (or "µs"), "ms", "s", "m", "h"). 140 | 141 | #### -v, --verbose 142 | 143 | Output logs verbosely. 144 | 145 | #### -V, --version 146 | 147 | Print version informatin. 148 | 149 | #### -h, --help 150 | 151 | Print usage. 152 | 153 | ### Example 154 | 155 | ``` 156 | arelo -t ./src -t ./html -p '**/*.{go,html,yaml}' -i '**/.*' -- go run . 157 | ``` 158 | 159 | #### `-t ./src -t ./html` 160 | 161 | Monitor files under the ./src or ./html directories. 162 | 163 | #### `-p '**/*.{go,html,yaml}'` 164 | 165 | Restart command when any *.go, *.html, *.yml file under the target, sub, and subsub... directories modified. 166 | 167 | #### `-i '**/.*'` 168 | 169 | Ignore files/directories whose name starts with '.'. 170 | 171 | #### `go run .` 172 | 173 | Command to run. 174 | 175 | ## Similar projects 176 | 177 | - [realize](https://github.com/oxequa/realize) 178 | - [fresh](https://github.com/gravityblast/fresh) 179 | - [gin](https://github.com/codegangsta/gin) 180 | - [go-task](https://github.com/go-task/task) 181 | - [air](https://github.com/cosmtrek/air) 182 | - [reflex](https://github.com/cespare/reflex) 183 | -------------------------------------------------------------------------------- /arelo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "io" 8 | "log" 9 | "os" 10 | "os/signal" 11 | "path" 12 | "path/filepath" 13 | "runtime/debug" 14 | "strconv" 15 | "strings" 16 | "sync" 17 | "syscall" 18 | "time" 19 | "unicode" 20 | 21 | "github.com/bmatcuk/doublestar/v4" 22 | "github.com/fsnotify/fsnotify" 23 | "github.com/spf13/pflag" 24 | "golang.org/x/xerrors" 25 | 26 | "github.com/makiuchi-d/arelo/fspoll" 27 | ) 28 | 29 | const ( 30 | waitForTerm = 5 * time.Second 31 | ) 32 | 33 | var ( 34 | version string 35 | usage = `Usage: arelo [OPTION]... -- COMMAND 36 | Run the COMMAND and restart when a file matches the pattern has been modified. 37 | 38 | Options:` 39 | targets = pflag.StringArrayP("target", "t", nil, "observation target `path` (default \"./\")") 40 | patterns = pflag.StringArrayP("pattern", "p", nil, "trigger pathname `glob` pattern (default \"**\")") 41 | ignores = pflag.StringArrayP("ignore", "i", nil, "ignore pathname `glob` pattern") 42 | delay = pflag.DurationP("delay", "d", time.Second, "`duration` to delay the restart of the command") 43 | restart = pflag.BoolP("restart", "r", false, "restart the command on exit") 44 | sigopt = pflag.StringP("signal", "s", "", "`signal` used to stop the command (default \"SIGTERM\")") 45 | nostdin = pflag.BoolP("no-stdin", "n", false, "do not forward stdin to the command") 46 | verbose = pflag.BoolP("verbose", "v", false, "verbose output") 47 | help = pflag.BoolP("help", "h", false, "display this message") 48 | showver = pflag.BoolP("version", "V", false, "display version") 49 | filters = pflag.StringArrayP("filter", "f", nil, "filter file system `event` (CREATE|WRITE|REMOVE|RENAME|CHMOD)") 50 | polling = pflag.Duration("polling", 0, "poll files at given `interval` instead of using fsnotify") 51 | ) 52 | 53 | func main() { 54 | pflag.Parse() 55 | if *help { 56 | fmt.Println("arelo version", versionstr()) 57 | fmt.Println(usage) 58 | pflag.PrintDefaults() 59 | return 60 | } 61 | if *showver { 62 | fmt.Println("arelo version", versionstr()) 63 | return 64 | } 65 | cmd := pflag.Args() 66 | if *targets == nil { 67 | *targets = []string{"./"} 68 | } 69 | if *patterns == nil { 70 | *patterns = []string{"**"} 71 | } 72 | *patterns = removeCurDirPrefix(*patterns) 73 | *ignores = removeCurDirPrefix(*ignores) 74 | sig, sigstr := parseSignalOption(*sigopt) 75 | filtOp, err := parseFilters(*filters) 76 | if err != nil { 77 | log.Fatalf("[ARELO] %v", err) 78 | } 79 | logVerbose("command: %q", cmd) 80 | logVerbose("targets: %q", *targets) 81 | logVerbose("patterns: %q", *patterns) 82 | logVerbose("ignores: %q", *ignores) 83 | logVerbose("filter: %v", filtOp) 84 | logVerbose("delay: %v", delay) 85 | logVerbose("signal: %s", sigstr) 86 | logVerbose("restart: %v", *restart) 87 | logVerbose("no-stdin: %v", *nostdin) 88 | if *polling != 0 { 89 | logVerbose("polling: true (%v)", *polling) 90 | } else { 91 | logVerbose("polling: false") 92 | } 93 | 94 | if len(cmd) == 0 { 95 | fmt.Fprintf(os.Stderr, "%s: COMMAND required.\n", os.Args[0]) 96 | os.Exit(1) 97 | } 98 | if sig == nil { 99 | fmt.Fprintf(os.Stderr, "%s: %s\n", os.Args[0], sigstr) 100 | os.Exit(1) 101 | } 102 | 103 | modC, errC, err := watcher(*targets, *patterns, *ignores, filtOp, *polling) 104 | if err != nil { 105 | log.Fatalf("[ARELO] wacher error: %v", err) 106 | } 107 | 108 | ctx, cancel := context.WithCancel(context.Background()) 109 | var wg sync.WaitGroup 110 | reload := runner(ctx, &wg, cmd, *delay, sig.(syscall.Signal), *restart, *nostdin) 111 | 112 | go func() { 113 | for { 114 | select { 115 | case <-ctx.Done(): 116 | return 117 | case name, ok := <-modC: 118 | if !ok { 119 | cancel() 120 | wg.Wait() 121 | log.Fatalf("[ARELO] wacher closed") 122 | return 123 | } 124 | reload <- name 125 | case err := <-errC: 126 | cancel() 127 | wg.Wait() 128 | log.Fatalf("[ARELO] wacher error: %v", err) 129 | return 130 | } 131 | } 132 | }() 133 | 134 | s := make(chan os.Signal, 1) 135 | signal.Notify(s, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) 136 | sig = <-s 137 | log.Printf("[ARELO] signal: %v", sig) 138 | cancel() 139 | wg.Wait() 140 | } 141 | 142 | func logVerbose(fmt string, args ...interface{}) { 143 | if *verbose { 144 | log.Printf("[ARELO] "+fmt, args...) 145 | } 146 | } 147 | 148 | func versionstr() string { 149 | if version != "" { 150 | return "v" + version 151 | } 152 | info, ok := debug.ReadBuildInfo() 153 | if !ok { 154 | return "(devel)" 155 | } 156 | return info.Main.Version 157 | } 158 | 159 | func removeCurDirPrefix(arr []string) []string { 160 | for i, s := range arr { 161 | if strings.HasPrefix(s, "./") { 162 | arr[i] = s[2:] 163 | } 164 | } 165 | return arr 166 | } 167 | 168 | func parseFilters(filters []string) (fsnotify.Op, error) { 169 | var op fsnotify.Op 170 | for _, f := range filters { 171 | switch strings.ToUpper(f) { 172 | case "CREATE": 173 | op |= fsnotify.Create 174 | case "WRITE": 175 | op |= fsnotify.Write 176 | case "REMOVE": 177 | op |= fsnotify.Remove 178 | case "RENAME": 179 | op |= fsnotify.Rename 180 | case "CHMOD": 181 | op |= fsnotify.Chmod 182 | default: 183 | return 0, xerrors.Errorf("invalid filter event: %s", f) 184 | } 185 | } 186 | return op, nil 187 | } 188 | 189 | func newWatcher(interval time.Duration) (fspoll.Watcher, error) { 190 | if interval == 0 { 191 | return fspoll.Wrap(fsnotify.NewWatcher()) 192 | } 193 | return fspoll.New(interval), nil 194 | } 195 | 196 | func watcher(targets, patterns, ignores []string, filtOp fsnotify.Op, interval time.Duration) (<-chan string, <-chan error, error) { 197 | w, err := newWatcher(interval) 198 | if err != nil { 199 | return nil, nil, err 200 | } 201 | if err := addTargets(w, targets, patterns, ignores); err != nil { 202 | return nil, nil, err 203 | } 204 | 205 | modC := make(chan string) 206 | errC := make(chan error) 207 | watchOp := ^filtOp 208 | 209 | go func() { 210 | defer close(modC) 211 | for { 212 | select { 213 | case event, ok := <-w.Events(): 214 | if !ok { 215 | errC <- xerrors.Errorf("watcher.Events closed") 216 | return 217 | } 218 | 219 | name := filepath.ToSlash(event.Name) 220 | logVerbose("event: %v %q", event.Op, name) 221 | 222 | if ignore, err := matchPatterns(name, ignores); err != nil { 223 | errC <- xerrors.Errorf("match ignores: %w", err) 224 | return 225 | } else if ignore { 226 | continue 227 | } 228 | 229 | if event.Has(watchOp) { 230 | if match, err := matchPatterns(name, patterns); err != nil { 231 | errC <- xerrors.Errorf("match patterns: %w", err) 232 | return 233 | } else if match { 234 | modC <- name 235 | } 236 | } 237 | 238 | // add watcher if new directory. 239 | if event.Has(fsnotify.Create) { 240 | fi, err := os.Stat(name) 241 | if err != nil { 242 | // ignore stat errors (notfound, permission, etc.) 243 | log.Printf("[ARELO] watcher: %v", err) 244 | } else if fi.IsDir() { 245 | err := addDirRecursive(w, name, patterns, ignores, modC) 246 | if err != nil { 247 | errC <- err 248 | return 249 | } 250 | } 251 | } 252 | 253 | case err, ok := <-w.Errors(): 254 | errC <- xerrors.Errorf("watcher.Errors (%v): %w", ok, err) 255 | return 256 | } 257 | } 258 | }() 259 | 260 | return modC, errC, nil 261 | } 262 | 263 | func matchPatterns(t string, pats []string) (bool, error) { 264 | if strings.HasPrefix(t, "./") { 265 | t = t[2:] 266 | } 267 | for _, p := range pats { 268 | m, err := doublestar.Match(p, t) 269 | if err != nil { 270 | return false, xerrors.Errorf("match(%v, %v): %w", p, t, err) 271 | } 272 | if m { 273 | return true, nil 274 | } 275 | } 276 | return false, nil 277 | } 278 | 279 | func addTargets(w fspoll.Watcher, targets, patterns, ignores []string) error { 280 | for _, t := range targets { 281 | t = path.Clean(t) 282 | fi, err := os.Stat(t) 283 | if err != nil { 284 | return xerrors.Errorf("stat: %w", err) 285 | } 286 | if fi.IsDir() { 287 | return addDirRecursive(w, t, patterns, ignores, nil) 288 | } 289 | logVerbose("watching target: %q", t) 290 | if err := w.Add(t); err != nil { 291 | return err 292 | } 293 | } 294 | return nil 295 | } 296 | 297 | func addDirRecursive(w fspoll.Watcher, t string, patterns, ignores []string, ch chan<- string) error { 298 | logVerbose("watching target: %q", t) 299 | err := w.Add(t) 300 | if err != nil { 301 | return xerrors.Errorf("wacher add: %w", err) 302 | } 303 | des, err := os.ReadDir(t) 304 | if err != nil { 305 | return xerrors.Errorf("read dir: %w", err) 306 | } 307 | for _, de := range des { 308 | name := path.Join(t, de.Name()) 309 | if ignore, err := matchPatterns(name, ignores); err != nil { 310 | return xerrors.Errorf("match ignores: %w", err) 311 | } else if ignore { 312 | continue 313 | } 314 | if ch != nil { 315 | if match, err := matchPatterns(name, patterns); err != nil { 316 | return xerrors.Errorf("match patterns: %w", err) 317 | } else if match { 318 | ch <- name 319 | } 320 | } 321 | if de.IsDir() { 322 | err = addDirRecursive(w, name, patterns, ignores, ch) 323 | if err != nil { 324 | return err 325 | } 326 | } 327 | } 328 | return nil 329 | } 330 | 331 | type bytesErr struct { 332 | bytes []byte 333 | err error 334 | } 335 | 336 | // stdinReader bypasses stdin to child processes 337 | // 338 | // cmd.Wait() blocks until stdin.Read() returns. 339 | // so stdinReader.Read() returns EOF when the child process exited. 340 | // see also: watchChild() 341 | type stdinReader struct { 342 | input <-chan bytesErr 343 | done <-chan struct{} 344 | } 345 | 346 | func (s *stdinReader) Read(b []byte) (int, error) { 347 | select { 348 | case be, ok := <-s.input: 349 | if !ok { 350 | return 0, io.EOF 351 | } 352 | return copy(b, be.bytes), be.err 353 | case <-s.done: 354 | return 0, io.EOF 355 | } 356 | } 357 | 358 | func runner(ctx context.Context, wg *sync.WaitGroup, cmd []string, delay time.Duration, sig syscall.Signal, autorestart, nostdin bool) chan<- string { 359 | reload := make(chan string) 360 | trigger := make(chan string) 361 | 362 | go func() { 363 | for name := range reload { 364 | // ignore restart when the trigger is not waiting 365 | select { 366 | case trigger <- name: 367 | default: 368 | } 369 | } 370 | }() 371 | 372 | var pcmd string // command string for display. 373 | for _, s := range cmd { 374 | if strings.ContainsFunc(s, unicode.IsSpace) { 375 | s = strconv.Quote(s) 376 | } 377 | pcmd += " " + s 378 | } 379 | pcmd = pcmd[1:] 380 | 381 | var stdinC chan bytesErr 382 | if !nostdin { 383 | stdinC = make(chan bytesErr) 384 | go func() { 385 | b1 := make([]byte, 255) 386 | b2 := make([]byte, 255) 387 | for { 388 | n, err := os.Stdin.Read(b1) 389 | stdinC <- bytesErr{b1[:n], err} 390 | b1, b2 = b2, b1 391 | } 392 | }() 393 | } 394 | 395 | wg.Add(1) 396 | go func() { 397 | defer wg.Done() 398 | for { 399 | select { 400 | case <-ctx.Done(): 401 | return 402 | default: 403 | } 404 | cmdctx, cancel := context.WithCancel(ctx) 405 | restart := make(chan struct{}) 406 | done := make(chan struct{}) 407 | 408 | go func() { 409 | log.Printf("[ARELO] start: %s", pcmd) 410 | err := runCmd(cmdctx, cmd, sig, stdinC) 411 | if err != nil { 412 | log.Printf("[ARELO] command error: %v", err) 413 | } else { 414 | log.Printf("[ARELO] command exit status 0") 415 | } 416 | if autorestart { 417 | close(restart) 418 | } 419 | 420 | close(done) 421 | }() 422 | 423 | select { 424 | case <-ctx.Done(): 425 | cancel() 426 | <-done 427 | return 428 | case name := <-trigger: 429 | log.Printf("[ARELO] triggered: %q", name) 430 | case <-restart: 431 | logVerbose("auto restart") 432 | } 433 | 434 | logVerbose("wait %v", delay) 435 | select { 436 | case <-ctx.Done(): 437 | cancel() 438 | <-done 439 | return 440 | case <-time.After(delay): 441 | } 442 | cancel() 443 | <-done // wait process closed 444 | } 445 | }() 446 | 447 | return reload 448 | } 449 | 450 | func runCmd(ctx context.Context, cmd []string, sig syscall.Signal, stdinC <-chan bytesErr) error { 451 | withStdin := stdinC != nil 452 | c := prepareCommand(cmd, withStdin) 453 | c.Stdout = os.Stdout 454 | c.Stderr = os.Stderr 455 | childctx, cancel := context.WithCancel(ctx) 456 | defer cancel() 457 | if withStdin { 458 | c.Stdin = bufio.NewReader(&stdinReader{ 459 | input: stdinC, 460 | done: childctx.Done(), 461 | }) 462 | } 463 | if err := c.Start(); err != nil { 464 | return err 465 | } 466 | 467 | var werrC chan error 468 | if withStdin { 469 | werrC = make(chan error, 1) 470 | go func() { 471 | err := watchChild(ctx, c) 472 | cancel() 473 | if err != nil { 474 | werrC <- xerrors.Errorf("watchChild: %w", err) 475 | } 476 | }() 477 | } 478 | 479 | var cerr error 480 | done := make(chan struct{}) 481 | go func() { 482 | cerr = c.Wait() 483 | close(done) 484 | }() 485 | 486 | select { 487 | case <-done: 488 | return cerr 489 | case err := <-werrC: 490 | log.Printf("[ARELO] %v", err) 491 | // kill childs 492 | case <-ctx.Done(): 493 | // kill childs 494 | } 495 | 496 | if err := killChilds(c, sig); err != nil { 497 | return xerrors.Errorf("kill childs: %w", err) 498 | } 499 | 500 | select { 501 | case <-done: 502 | case <-time.After(waitForTerm): 503 | if err := killChilds(c, syscall.SIGKILL); err != nil { 504 | return xerrors.Errorf("kill childs (SIGKILL): %w", err) 505 | } 506 | <-done 507 | } 508 | 509 | if cerr != nil { 510 | return xerrors.Errorf("process canceled: %w", cerr) 511 | } 512 | return nil 513 | } 514 | -------------------------------------------------------------------------------- /arelo_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path" 6 | "reflect" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestWatcherFsnotify(t *testing.T) { 12 | t.Parallel() 13 | testWatcher(t, 0) 14 | } 15 | 16 | func TestWatcherFspoll(t *testing.T) { 17 | t.Parallel() 18 | testWatcher(t, time.Second/10) 19 | } 20 | 21 | func testWatcher(t *testing.T, polling time.Duration) { 22 | tmpdir := t.TempDir() 23 | 24 | dirs := []string{ 25 | path.Join(tmpdir, "target"), 26 | path.Join(tmpdir, "target", "sub"), 27 | path.Join(tmpdir, "target", "ignore"), 28 | path.Join(tmpdir, "mv", "mvsub"), 29 | } 30 | for _, d := range dirs { 31 | if err := os.MkdirAll(d, 0755); err != nil { 32 | t.Fatalf("MkdirAll: %v", err) 33 | } 34 | } 35 | 36 | targets := []string{tmpdir + "/target"} 37 | ignores := []string{"**/ignore"} 38 | patterns := []string{"**/file"} 39 | 40 | modC, errC, err := watcher(targets, patterns, ignores, 0, polling) 41 | if err != nil { 42 | t.Fatalf("watcher: %v", err) 43 | } 44 | 45 | // move directory into the target to check the subdirectories are watched. 46 | if err := os.Rename(path.Join(tmpdir, "mv"), path.Join(tmpdir, "target", "mv")); err != nil { 47 | t.Fatalf("Rename: %v", err) 48 | } 49 | 50 | tests := []struct { 51 | file string 52 | detect bool 53 | }{ 54 | {path.Join(tmpdir, "target", "file"), true}, 55 | {path.Join(tmpdir, "target", "file2"), false}, 56 | {path.Join(tmpdir, "file"), false}, 57 | {path.Join(tmpdir, "target", "sub", "file"), true}, 58 | {path.Join(tmpdir, "target", "ignore", "file"), false}, 59 | {path.Join(tmpdir, "target", "mv", "file"), true}, 60 | {path.Join(tmpdir, "target", "mv", "mvsub", "file"), true}, 61 | } 62 | for _, test := range tests { 63 | <-time.After(time.Second / 5) 64 | clearChan(modC, errC) 65 | t.Logf("touch %v => detect %v", test.file, test.detect) 66 | touchFile(test.file) 67 | select { 68 | case f := <-modC: 69 | if f != test.file { 70 | t.Fatalf("unexpected file modified: %q, wants %q", f, test.file) 71 | } 72 | if !test.detect { 73 | t.Fatalf("must not be detect: %q", f) 74 | } 75 | case e := <-errC: 76 | t.Fatalf("watcher error: %v", e) 77 | case <-time.After(time.Second / 5): 78 | if test.detect { 79 | t.Fatalf("must be detect: %q", test.file) 80 | } 81 | } 82 | } 83 | } 84 | 85 | func clearChan(c <-chan string, ce <-chan error) { 86 | for { 87 | select { 88 | case <-c: 89 | case <-ce: 90 | default: 91 | return 92 | } 93 | } 94 | } 95 | 96 | func touchFile(file string) { 97 | os.WriteFile(file, []byte("a"), 0644) 98 | } 99 | 100 | func TestMatchPatterns(t *testing.T) { 101 | tests := []struct { 102 | t, pat string 103 | wants bool 104 | }{ 105 | {"ab/cd/efg", "**/efg", true}, 106 | {"ab/cd/efg", "*/efg", false}, 107 | {"./abc.efg", "**/*.efg", true}, 108 | {"./abc.efg", "*.efg", true}, 109 | {"./.abc", "**/.*", true}, 110 | {"./.abc", ".*", true}, 111 | {"./", "", true}, 112 | {"./", "*", true}, 113 | {"./", "**", true}, 114 | } 115 | 116 | for _, test := range tests { 117 | r, err := matchPatterns(test.t, []string{test.pat}) 118 | if err != nil { 119 | t.Fatalf("matchPatterns(%v, {%v}): %v", test.t, test.pat, err) 120 | } 121 | if r != test.wants { 122 | t.Fatalf("matchPatterns(%v, {%v}) = %v wants %v", test.t, test.pat, r, test.wants) 123 | } 124 | } 125 | } 126 | 127 | func TestRemoveCurDirPrefix(t *testing.T) { 128 | arr := []string{ 129 | ".a", 130 | ".aa", 131 | "./.*", 132 | "./abc", 133 | "../a", 134 | ".", 135 | "./", 136 | "abc", 137 | "a./", 138 | "a/./b", 139 | } 140 | exp := []string{ 141 | ".a", 142 | ".aa", 143 | ".*", 144 | "abc", 145 | "../a", 146 | ".", 147 | "", 148 | "abc", 149 | "a./", 150 | "a/./b", 151 | } 152 | r := removeCurDirPrefix(arr) 153 | if !reflect.DeepEqual(r, exp) { 154 | t.Fatalf("removeCurDirPrefix: %#v wants %#v", r, exp) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /arelo_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "os/signal" 12 | "strings" 13 | "syscall" 14 | 15 | "golang.org/x/xerrors" 16 | ) 17 | 18 | func parseSignalOption(str string) (os.Signal, string) { 19 | switch strings.ToUpper(str) { 20 | case "1", "HUP", "SIGHUP", "SIG_HUP": 21 | return syscall.SIGHUP, "SIGHUP" 22 | case "2", "INT", "SIGINT", "SIG_INT": 23 | return syscall.SIGINT, "SIGINT" 24 | case "3", "QUIT", "SIGQUIT", "SIG_QUIT": 25 | return syscall.SIGQUIT, "SIGQUIT" 26 | case "9", "KILL", "SIGKILL", "SIG_KILL": 27 | return syscall.SIGKILL, "SIGKILL" 28 | case "10", "USR1", "SIGUSR1", "SIG_USR1": 29 | return syscall.SIGUSR1, "SIGUSR1" 30 | case "12", "USR2", "SIGUSR2", "SIG_USR2": 31 | return syscall.SIGUSR2, "SIGUSR2" 32 | case "15", "TERM", "SIGTERM", "SIG_TERM", "": 33 | return syscall.SIGTERM, "SIGTERM" 34 | case "28", "WINCH", "SIGWINCH", "SIG_WINCH": 35 | return syscall.SIGWINCH, "SIGWINCH" 36 | } 37 | 38 | return nil, fmt.Sprintf("unspported signal: %s", str) 39 | } 40 | 41 | var sigchldC chan os.Signal 42 | 43 | func clearChBuf[T any](c <-chan T) { 44 | for { 45 | select { 46 | case <-c: 47 | default: 48 | return 49 | } 50 | } 51 | } 52 | 53 | func prepareCommand(cmd []string, withstdin bool) *exec.Cmd { 54 | if withstdin { 55 | // On UNIX like OS, termination of child process is notified by SIGCHLD. 56 | if sigchldC == nil { 57 | sigchldC = make(chan os.Signal, 1) 58 | signal.Notify(sigchldC, syscall.SIGCHLD) 59 | } else { 60 | clearChBuf(sigchldC) 61 | } 62 | } 63 | c := exec.Command(cmd[0], cmd[1:]...) 64 | c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 65 | return c 66 | } 67 | 68 | // watchChild detects the termination of the child process by using SIGCHLD and the wait4 syscall. 69 | func watchChild(ctx context.Context, c *exec.Cmd) error { 70 | for { 71 | select { 72 | case <-ctx.Done(): 73 | return nil 74 | case <-sigchldC: 75 | } 76 | 77 | var wstatus syscall.WaitStatus 78 | var rusage syscall.Rusage 79 | pid, err := syscall.Wait4(c.Process.Pid, &wstatus, syscall.WNOHANG, &rusage) 80 | if errors.Is(err, syscall.ECHILD) || (pid == c.Process.Pid && wstatus.Exited()) { 81 | return nil 82 | } 83 | if err != nil { 84 | return xerrors.Errorf("syscall.Wait4: %w", err) 85 | } 86 | } 87 | } 88 | 89 | func killChilds(c *exec.Cmd, sig syscall.Signal) error { 90 | err := syscall.Kill(-c.Process.Pid, sig) 91 | if err == nil && sig != syscall.SIGKILL && sig != syscall.SIGCONT { 92 | // prosess can be stopped, so it must be start by SIGCONT. 93 | err = syscall.Kill(-c.Process.Pid, syscall.SIGCONT) 94 | } 95 | return err 96 | } 97 | -------------------------------------------------------------------------------- /arelo_unix_test.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package main 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | "testing" 9 | ) 10 | 11 | func TestParseSignalOption(t *testing.T) { 12 | tests := []struct { 13 | inputs []string 14 | sig os.Signal 15 | out string 16 | }{ 17 | {[]string{"1", "HUP", "SIGHUP", "SIG_HUP", "hup", "SigHup"}, syscall.SIGHUP, "SIGHUP"}, 18 | {[]string{"2", "INT", "SIGINT", "SIG_INT", "int", "SigInt"}, syscall.SIGINT, "SIGINT"}, 19 | {[]string{"3", "QUIT", "SIGQUIT", "SIG_QUIT", "SigQuit"}, syscall.SIGQUIT, "SIGQUIT"}, 20 | {[]string{"9", "KILL", "SIGKILL", "SIG_KILL", "SIgKill"}, syscall.SIGKILL, "SIGKILL"}, 21 | {[]string{"10", "USR1", "SIGUSR1", "SIG_USR1", "SIgUsr1"}, syscall.SIGUSR1, "SIGUSR1"}, 22 | {[]string{"12", "USR2", "SIGUSR2", "SIG_USR2", "SIgUsr2"}, syscall.SIGUSR2, "SIGUSR2"}, 23 | {[]string{"15", "TERM", "SIGTERM", "SIG_TERM", "SIgTerm", ""}, syscall.SIGTERM, "SIGTERM"}, 24 | {[]string{"28", "WINCH", "SIGWINCH", "SIG_WINCH", "SigWinch"}, syscall.SIGWINCH, "SIGWINCH"}, 25 | } 26 | for _, test := range tests { 27 | for _, in := range test.inputs { 28 | s, o := parseSignalOption(in) 29 | if s != test.sig || o != test.out { 30 | t.Fatalf("%q: got %q, %q, wants %q, %q", in, s, o, test.sig, test.out) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /arelo_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package main 4 | 5 | import ( 6 | "context" 7 | "os" 8 | "os/exec" 9 | "strconv" 10 | "syscall" 11 | "time" 12 | 13 | "golang.org/x/sys/windows" 14 | "golang.org/x/xerrors" 15 | ) 16 | 17 | const STILL_ACTIVE = 259 18 | 19 | var procC chan windows.Handle 20 | 21 | func parseSignalOption(str string) (os.Signal, string) { 22 | if str == "" { 23 | return syscall.SIGTERM, "SIGTERM" 24 | } 25 | return nil, "Signal option (--signal, -s) is not available on Windows." 26 | } 27 | 28 | func prepareCommand(cmd []string, _ bool) *exec.Cmd { 29 | c := exec.Command(cmd[0], cmd[1:]...) 30 | c.SysProcAttr = &syscall.SysProcAttr{ 31 | CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP, 32 | } 33 | return c 34 | } 35 | 36 | // watchChild detects the termination of the child process by polling GetExitCodeProcess. 37 | func watchChild(ctx context.Context, c *exec.Cmd) error { 38 | p, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(c.Process.Pid)) 39 | if err != nil { 40 | return xerrors.Errorf("OpenProcess: %w", err) 41 | } 42 | defer windows.CloseHandle(p) 43 | 44 | for { 45 | select { 46 | case <-ctx.Done(): 47 | return nil 48 | case <-time.After(*delay / 2): 49 | } 50 | 51 | var code uint32 52 | err := windows.GetExitCodeProcess(p, &code) 53 | if err != nil { 54 | return xerrors.Errorf("GetExitCodeProcess: %w", err) 55 | } 56 | if code != STILL_ACTIVE { 57 | return nil 58 | } 59 | } 60 | } 61 | 62 | func killChilds(c *exec.Cmd, _ syscall.Signal) error { 63 | kill := exec.Command("TASKKILL", "/T", "/F", "/PID", strconv.Itoa(c.Process.Pid)) 64 | if *verbose { 65 | kill.Stderr = c.Stderr 66 | kill.Stdout = c.Stderr 67 | } 68 | return kill.Run() 69 | } 70 | -------------------------------------------------------------------------------- /fspoll/fsnotify.go: -------------------------------------------------------------------------------- 1 | package fspoll 2 | 3 | import ( 4 | "github.com/fsnotify/fsnotify" 5 | ) 6 | 7 | // Wrapper of a fsnotify.Watcher. 8 | type Wrapper struct { 9 | *fsnotify.Watcher 10 | } 11 | 12 | var _ Watcher = Wrapper{} 13 | 14 | // Wrap returns a wrapping fsnotify.Watcher. 15 | func Wrap(w *fsnotify.Watcher, err error) (Wrapper, error) { 16 | return Wrapper{w}, err 17 | } 18 | 19 | // Events returns Events channel of wrapping fsnotify.Watcher. 20 | func (w Wrapper) Events() <-chan Event { 21 | return w.Watcher.Events 22 | } 23 | 24 | // Errors returns Errors channel of wrapping fsnotify.Watcher. 25 | func (w Wrapper) Errors() <-chan error { 26 | return w.Watcher.Errors 27 | } 28 | -------------------------------------------------------------------------------- /fspoll/fspoll.go: -------------------------------------------------------------------------------- 1 | // fspoll provides polling file change watcher. 2 | package fspoll 3 | 4 | import ( 5 | "github.com/fsnotify/fsnotify" 6 | ) 7 | 8 | type Event = fsnotify.Event 9 | type Op = fsnotify.Op 10 | 11 | const ( 12 | Create = fsnotify.Create 13 | Write = fsnotify.Write 14 | Remove = fsnotify.Remove 15 | Rename = fsnotify.Rename 16 | Chmod = fsnotify.Chmod 17 | ) 18 | 19 | var ( 20 | ErrNonExistentWatch = fsnotify.ErrNonExistentWatch 21 | ErrEventOverflow = fsnotify.ErrEventOverflow 22 | ErrClosed = fsnotify.ErrClosed 23 | ) 24 | 25 | // Watcher is a common interface for fspoll and fsnotify 26 | type Watcher interface { 27 | 28 | // Add starts watching the path for changes. 29 | Add(name string) error 30 | 31 | // Close stops all watches and closes the channels. 32 | Close() error 33 | 34 | // Remove stops watching the specified path. 35 | Remove(name string) error 36 | 37 | // WatchList returns a list of watching path names. 38 | WatchList() []string 39 | 40 | // Events returns a channel that receives filesystem events. 41 | Events() <-chan Event 42 | 43 | // Errors returns a channel that receives errors. 44 | Errors() <-chan error 45 | } 46 | -------------------------------------------------------------------------------- /fspoll/fspoll_test.go: -------------------------------------------------------------------------------- 1 | package fspoll_test 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "reflect" 8 | "runtime" 9 | "testing" 10 | "time" 11 | 12 | "github.com/fsnotify/fsnotify" 13 | "github.com/makiuchi-d/arelo/fspoll" 14 | ) 15 | 16 | const ( 17 | eventWaitTimeout = time.Second / 2 18 | pollingInterval = time.Second / 10 19 | ) 20 | 21 | func TestFsnotify(t *testing.T) { 22 | t.Parallel() 23 | newW := func() fspoll.Watcher { 24 | w, _ := fspoll.Wrap(fsnotify.NewWatcher()) 25 | return w 26 | } 27 | 28 | testSingleFile(t, newW) 29 | testDirectory(t, newW) 30 | } 31 | 32 | func TestFspoll(t *testing.T) { 33 | t.Parallel() 34 | newW := func() fspoll.Watcher { 35 | return fspoll.New(pollingInterval) 36 | } 37 | 38 | testSingleFile(t, newW) 39 | testDirectory(t, newW) 40 | } 41 | 42 | func must(t *testing.T, err error) { 43 | if err != nil { 44 | _, f, l, _ := runtime.Caller(1) 45 | t.Fatalf("%v:%v: %v", f, l, err) 46 | } 47 | } 48 | 49 | func waitEvent(t *testing.T, w fspoll.Watcher, name string, op fspoll.Op) { 50 | timeout := time.After(eventWaitTimeout) 51 | for { 52 | select { 53 | case <-timeout: 54 | t.Fatalf("timeout: waiting %v for %q", op, name) 55 | 56 | case ev, ok := <-w.Events(): 57 | t.Logf("event: %v", ev) 58 | if !ok { 59 | t.Fatal("watcher closed") 60 | } 61 | if ev.Op.Has(op) && ev.Name == name { 62 | return // ok 63 | } 64 | } 65 | } 66 | } 67 | 68 | func waitNoEvent(t *testing.T, w fspoll.Watcher) { 69 | timeout := time.After(eventWaitTimeout) 70 | select { 71 | case <-timeout: 72 | return // ok 73 | 74 | case ev, ok := <-w.Events(): 75 | if !ok { 76 | t.Fatalf("watcher closed") 77 | } 78 | t.Fatalf("unexpected event: %v", ev) 79 | } 80 | } 81 | 82 | func testSingleFile[W fspoll.Watcher](t *testing.T, newW func() W) { 83 | t.Run("SingleFile", func(t *testing.T) { 84 | t.Parallel() 85 | w := newW() 86 | 87 | dir := t.TempDir() 88 | fname := filepath.Join(dir, "file") 89 | 90 | err := w.Add(fname) 91 | if err == nil { 92 | t.Fatal("Add no available file must be error") 93 | } 94 | 95 | t.Log("create") 96 | fp, err := os.Create(fname) 97 | must(t, err) 98 | defer fp.Close() 99 | 100 | must(t, w.Add(fname)) 101 | 102 | t.Log("watchlist") 103 | l := w.WatchList() 104 | t.Log(l) 105 | exp := []string{fname} 106 | if !reflect.DeepEqual(l, exp) { 107 | t.Fatalf("WatchList: %v, wants %v", l, exp) 108 | } 109 | 110 | t.Log("write") 111 | fp.Write([]byte("a")) 112 | waitEvent(t, w, fname, fspoll.Write) 113 | 114 | t.Log("chmod") 115 | fp.Chmod(0700) 116 | waitEvent(t, w, fname, fspoll.Chmod) 117 | 118 | t.Log("remove") 119 | fp.Close() 120 | os.Remove(fname) 121 | waitEvent(t, w, fname, fspoll.Remove) 122 | 123 | t.Log("create after removed") 124 | fp2, err := os.Create(fname) 125 | must(t, err) 126 | defer fp2.Close() 127 | waitNoEvent(t, w) 128 | 129 | t.Log("call Remove after removed") 130 | err = w.Remove(fname) 131 | if !errors.Is(err, fspoll.ErrNonExistentWatch) { 132 | t.Fatalf("watcher.Remove must be ErrNonExistentWatch: err=%v", err) 133 | } 134 | 135 | t.Log("close watcher") 136 | w.Close() 137 | select { 138 | case _, ok := <-w.Events(): 139 | if ok { 140 | t.Fatalf("Event channel is not closed") 141 | } 142 | case <-time.After(pollingInterval * 2): 143 | t.Fatalf("Events channel is not closed") 144 | } 145 | select { 146 | case _, ok := <-w.Errors(): 147 | if ok { 148 | t.Fatalf("Errors channel is not closed") 149 | } 150 | case <-time.After(pollingInterval * 2): 151 | t.Fatalf("Errors channel is not closed") 152 | } 153 | }) 154 | } 155 | 156 | func testDirectory[W fspoll.Watcher](t *testing.T, newW func() W) { 157 | t.Run("Directory", func(t *testing.T) { 158 | t.Parallel() 159 | dir := t.TempDir() 160 | dname1 := filepath.Join(dir, "dir1") 161 | fname1 := filepath.Join(dname1, "file1") 162 | dname2 := filepath.Join(dname1, "dir2") 163 | fname2 := filepath.Join(dname2, "file2") 164 | 165 | w := newW() 166 | 167 | os.Mkdir(dname1, 0755) 168 | must(t, w.Add(dname1)) 169 | 170 | t.Log("chmod basedir") 171 | os.Chmod(dir, 0700) 172 | waitNoEvent(t, w) 173 | 174 | t.Log("create file") 175 | fp1, err := os.Create(fname1) 176 | must(t, err) 177 | defer fp1.Close() 178 | waitEvent(t, w, fname1, fspoll.Create) 179 | 180 | t.Log("write file") 181 | fp1.Write([]byte("a")) 182 | waitEvent(t, w, fname1, fspoll.Write) 183 | 184 | t.Log("chmod file") 185 | os.Chmod(fname1, 0700) 186 | waitEvent(t, w, fname1, fspoll.Chmod) 187 | 188 | t.Log("create subdir") 189 | os.Mkdir(dname2, 0755) 190 | waitEvent(t, w, dname2, fspoll.Create) 191 | 192 | t.Log("create file in subdir") 193 | fp2, err := os.Create(fname2) 194 | must(t, err) 195 | defer fp2.Close() 196 | waitNoEvent(t, w) 197 | 198 | t.Log("remove file") 199 | os.Remove(fname1) 200 | waitEvent(t, w, fname1, fspoll.Remove) 201 | 202 | t.Log("chmod dir") 203 | os.Chmod(dname1, 0700) 204 | waitEvent(t, w, dname1, fspoll.Chmod) 205 | 206 | t.Log("remove from watcher") 207 | must(t, w.Remove(dname1)) 208 | 209 | t.Log("write after removed") 210 | fp1.Write([]byte("a")) 211 | waitNoEvent(t, w) 212 | }) 213 | } 214 | -------------------------------------------------------------------------------- /fspoll/poller.go: -------------------------------------------------------------------------------- 1 | package fspoll 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | // Poller is a polling watcher for file changes. 14 | type Poller struct { 15 | events chan Event 16 | errors chan error 17 | 18 | interval time.Duration 19 | 20 | ctx context.Context 21 | cancel context.CancelFunc 22 | wg sync.WaitGroup 23 | 24 | mu sync.RWMutex 25 | closed bool 26 | cancellers map[string]context.CancelFunc 27 | } 28 | 29 | // New generates a new Poller. 30 | func New(interval time.Duration) *Poller { 31 | ctx, cancel := context.WithCancel(context.Background()) 32 | p := &Poller{ 33 | events: make(chan Event, 1), 34 | errors: make(chan error, 1), 35 | interval: interval, 36 | ctx: ctx, 37 | cancel: cancel, 38 | cancellers: make(map[string]context.CancelFunc), 39 | } 40 | go func() { 41 | <-p.ctx.Done() 42 | p.wg.Wait() 43 | close(p.events) 44 | close(p.errors) 45 | }() 46 | return p 47 | } 48 | 49 | // Add starts watching the path for changes. 50 | func (p *Poller) Add(name string) error { 51 | p.mu.Lock() 52 | defer p.mu.Unlock() 53 | 54 | if p.closed { 55 | return ErrClosed 56 | } 57 | 58 | fi, err := os.Stat(name) 59 | if err != nil { 60 | return err 61 | } 62 | 63 | if _, ok := p.cancellers[name]; ok { 64 | return nil // already watching 65 | } 66 | 67 | ctx, cancel := context.WithCancel(p.ctx) 68 | p.cancellers[name] = cancel 69 | 70 | ready := make(chan struct{}) 71 | p.wg.Add(1) 72 | go func() { 73 | defer p.wg.Done() 74 | if fi.IsDir() { 75 | p.pollingDir(ctx, name, fi, ready) 76 | } else { 77 | p.pollingFile(ctx, name, fi, ready) 78 | } 79 | cancel() // to prevent deadlock: ready might not be closed 80 | _ = p.Remove(name) 81 | }() 82 | 83 | select { 84 | case <-ctx.Done(): 85 | case <-ready: 86 | } 87 | 88 | return nil 89 | } 90 | 91 | // Close stops all watches and closes the channels. 92 | func (p *Poller) Close() error { 93 | p.mu.Lock() 94 | defer p.mu.Unlock() 95 | 96 | p.closed = true 97 | p.cancel() 98 | 99 | return nil 100 | } 101 | 102 | // Remove stops watching the specified path. 103 | func (p *Poller) Remove(name string) error { 104 | p.mu.Lock() 105 | defer p.mu.Unlock() 106 | 107 | if p.closed { 108 | return nil 109 | } 110 | 111 | cancel, ok := p.cancellers[name] 112 | if !ok { 113 | return ErrNonExistentWatch 114 | } 115 | 116 | cancel() 117 | delete(p.cancellers, name) 118 | 119 | return nil 120 | } 121 | 122 | // WatchList returns a list of watching path names. 123 | func (p *Poller) WatchList() []string { 124 | p.mu.RLock() 125 | defer p.mu.RUnlock() 126 | 127 | names := make([]string, 0, len(p.cancellers)) 128 | for name := range p.cancellers { 129 | names = append(names, name) 130 | } 131 | return names 132 | } 133 | 134 | // Events returns a channel that receives filesystem events. 135 | func (p *Poller) Events() <-chan Event { 136 | return p.events 137 | } 138 | 139 | // Errors returns a channel that receives errors. 140 | func (p *Poller) Errors() <-chan error { 141 | return p.errors 142 | } 143 | 144 | func (p *Poller) isClosed() bool { 145 | p.mu.RLock() 146 | defer p.mu.RUnlock() 147 | return p.closed 148 | } 149 | 150 | func (p *Poller) sendEvent(ctx context.Context, name string, op Op) bool { 151 | if p.isClosed() { 152 | return false 153 | } 154 | select { 155 | case <-ctx.Done(): 156 | return false 157 | case p.events <- Event{Name: name, Op: op}: 158 | return true 159 | } 160 | } 161 | 162 | func (p *Poller) sendError(ctx context.Context, err error) bool { 163 | if p.isClosed() { 164 | return false 165 | } 166 | select { 167 | case <-ctx.Done(): 168 | return false 169 | case p.errors <- err: 170 | return true 171 | } 172 | } 173 | 174 | type stat struct { 175 | mode fs.FileMode 176 | modtime time.Time 177 | size int64 178 | } 179 | 180 | func makeStat(fi fs.FileInfo) stat { 181 | return stat{ 182 | mode: fi.Mode(), 183 | modtime: fi.ModTime(), 184 | size: fi.Size(), 185 | } 186 | } 187 | 188 | func (p *Poller) pollingDir(ctx context.Context, name string, fi fs.FileInfo, ready chan struct{}) { 189 | des, err := os.ReadDir(name) 190 | if err != nil { 191 | if !errors.Is(err, fs.ErrNotExist) { 192 | p.sendError(ctx, err) 193 | } 194 | return 195 | } 196 | 197 | mode := fi.Mode() 198 | prev := make(map[string]stat) 199 | cur := make(map[string]stat) 200 | 201 | for _, de := range des { 202 | fi, err := de.Info() 203 | if err != nil { 204 | if !errors.Is(err, fs.ErrNotExist) { 205 | if !p.sendError(ctx, err) { 206 | return 207 | } 208 | } 209 | continue 210 | } 211 | prev[de.Name()] = makeStat(fi) 212 | } 213 | 214 | close(ready) 215 | t := time.NewTicker(p.interval) 216 | for { 217 | select { 218 | case <-ctx.Done(): 219 | return 220 | case <-t.C: 221 | } 222 | 223 | // check mode of target dir 224 | fi, err := os.Stat(name) 225 | if err != nil { 226 | if errors.Is(err, fs.ErrNotExist) { 227 | return 228 | } 229 | if !p.sendError(ctx, err) { 230 | return 231 | } 232 | } 233 | if m := fi.Mode(); m != mode { 234 | if !p.sendEvent(ctx, name, Chmod) { 235 | return 236 | } 237 | mode = m 238 | } 239 | 240 | // check entries in the target dir 241 | des, err := os.ReadDir(name) 242 | if err != nil { 243 | if errors.Is(err, fs.ErrNotExist) { 244 | return 245 | } 246 | if !p.sendError(ctx, err) { 247 | return 248 | } 249 | continue 250 | } 251 | 252 | for _, de := range des { 253 | basename := de.Name() 254 | fullname := filepath.Join(name, basename) 255 | 256 | fi, err := de.Info() 257 | if err != nil { 258 | if errors.Is(err, fs.ErrNotExist) { 259 | if !p.sendEvent(ctx, fullname, Remove) { 260 | return 261 | } 262 | } else { 263 | if !p.sendError(ctx, err) { 264 | return 265 | } 266 | } 267 | continue 268 | } 269 | 270 | cs := makeStat(fi) 271 | cur[basename] = cs 272 | ps, ok := prev[basename] 273 | if !ok { 274 | if !p.sendEvent(ctx, fullname, Create) { 275 | return 276 | } 277 | continue 278 | } 279 | delete(prev, basename) 280 | 281 | if cs.mode != ps.mode { 282 | if !p.sendEvent(ctx, fullname, Chmod) { 283 | return 284 | } 285 | } 286 | if !fi.IsDir() { // ignore changes in the subdir 287 | if cs.modtime != ps.modtime || cs.size != ps.size { 288 | if !p.sendEvent(ctx, fullname, Write) { 289 | return 290 | } 291 | } 292 | } 293 | } 294 | 295 | for n := range prev { 296 | if !p.sendEvent(ctx, filepath.Join(name, n), Remove) { 297 | return 298 | } 299 | } 300 | clear(prev) 301 | prev, cur = cur, prev 302 | } 303 | } 304 | 305 | func (p *Poller) pollingFile(ctx context.Context, name string, fi fs.FileInfo, ready chan struct{}) { 306 | mode := fi.Mode() 307 | modt := fi.ModTime() 308 | size := fi.Size() 309 | 310 | close(ready) 311 | t := time.NewTicker(p.interval) 312 | for { 313 | select { 314 | case <-ctx.Done(): 315 | return 316 | case <-t.C: 317 | } 318 | 319 | fi, err := os.Stat(name) 320 | if err != nil { 321 | if errors.Is(err, fs.ErrNotExist) { 322 | p.sendEvent(ctx, name, Remove) 323 | return 324 | } 325 | if !p.sendError(ctx, err) { 326 | return 327 | } 328 | continue 329 | } 330 | 331 | if m := fi.Mode(); m != mode { 332 | mode = m 333 | if !p.sendEvent(ctx, name, Chmod) { 334 | return 335 | } 336 | } 337 | 338 | if m, s := fi.ModTime(), fi.Size(); m != modt || s != size { 339 | modt = m 340 | size = s 341 | if !p.sendEvent(ctx, name, Write) { 342 | return 343 | } 344 | } 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/makiuchi-d/arelo 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/bmatcuk/doublestar/v4 v4.8.1 7 | github.com/fsnotify/fsnotify v1.9.0 8 | github.com/spf13/pflag v1.0.6 9 | golang.org/x/sys v0.33.0 10 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= 2 | github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 3 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 4 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 5 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 6 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 7 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 8 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 9 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 10 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 11 | --------------------------------------------------------------------------------