From 63da204a318b303523a5b1f1993815b92c435228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 25 Jun 2024 23:17:49 +0800 Subject: [PATCH] Init commit --- .github/renovate.json | 23 +++++++ .github/workflows/lint.yml | 37 ++++++++++ .github/workflows/test.yml | 94 +++++++++++++++++++++++++ .gitignore | 3 + .golangci.yml | 18 +++++ LICENSE | 14 ++++ Makefile | 21 ++++++ README.md | 47 +++++++++++++ go.mod | 16 +++++ go.sum | 16 +++++ watcher.go | 136 +++++++++++++++++++++++++++++++++++++ watcher_test.go | 109 +++++++++++++++++++++++++++++ 12 files changed, 534 insertions(+) create mode 100644 .github/renovate.json create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 watcher.go create mode 100644 watcher_test.go diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..3515733 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "commitMessagePrefix": "[dependencies]", + "branchName": "main", + "extends": [ + "config:base", + ":disableRateLimiting" + ], + "packageRules": [ + { + "matchManagers": [ + "github-actions" + ], + "groupName": "github-actions" + }, + { + "matchManagers": [ + "gomod" + ], + "groupName": "gomod" + } + ] +} \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..40cdafb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,37 @@ +name: lint + +on: + push: + branches: + - main + paths-ignore: + - '**.md' + - '.github/**' + - '!.github/workflows/lint.yml' + pull_request: + branches: + - main + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ^1.22 + - name: Cache go module + uses: actions/cache@v3 + with: + path: | + ~/go/pkg/mod + key: go-${{ hashFiles('**/go.sum') }} + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1484eb4 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,94 @@ +name: test + +on: + push: + branches: + - main + paths-ignore: + - '**.md' + - '.github/**' + - '!.github/workflows/test.yml' + pull_request: + branches: + - main + +jobs: + build: + name: Linux + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ^1.22 + - name: Build + run: | + make test + build_go120: + name: Linux (Go 1.20) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ~1.20 + continue-on-error: true + - name: Build + run: | + make test + build_go121: + name: Linux (Go 1.21) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ~1.21 + continue-on-error: true + - name: Build + run: | + make test + build_windows: + name: Windows + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ^1.22 + continue-on-error: true + - name: Build + run: | + make test + build_darwin: + name: macOS + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v4 + with: + go-version: ^1.22 + continue-on-error: true + - name: Build + run: | + make test \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1298ea --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/.idea/ +/vendor/ +.DS_Store diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d3147cf --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,18 @@ +linters: + disable-all: true + enable: + - gofumpt + - govet + - gci + - staticcheck + +run: + go: 1.22 + +linters-settings: + gci: + custom-order: true + sections: + - standard + - prefix(github.com/sagernet/) + - default \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e3e29e --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright (C) 2022 by nekohasekai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..91ac97b --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +fmt: + @gofumpt -l -w . + @gofmt -s -w . + @gci write --custom-order -s standard -s "prefix(github.com/sagernet/)" -s "default" . + +fmt_install: + go install -v mvdan.cc/gofumpt@latest + go install -v github.com/daixiang0/gci@latest + +lint: + GOOS=linux golangci-lint run ./... + GOOS=android golangci-lint run ./... + GOOS=windows golangci-lint run ./... + GOOS=darwin golangci-lint run ./... + GOOS=freebsd golangci-lint run ./... + +lint_install: + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +test: + go test $(shell go list ./... | grep -v /internal/) diff --git a/README.md b/README.md new file mode 100644 index 0000000..246205a --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# fswatch + +![Test](https://github.com/sagernet/fswatch/actions/workflows/test.yml/badge.svg) +![Lint](https://github.com/sagernet/fswatch/actions/workflows/lint.yml/badge.svg) +[![Go Reference](https://pkg.go.dev/badge/github.com/sagernet/fswatch.svg)](https://pkg.go.dev/github.com/sagernet/fswatch) + +fswatch is a simple [fsnotify] wrapper to watch file updates correctly. + +[fsnotify]: https://github.com/fsnotify/fsnotify + +Install +--- + +```bash +go get github.com/sagernet/fswatch +``` + +Example +--- + +```go +package main + +import ( + "log" + + "github.com/sagernet/fswatch" +) + +func main() { + var watchPath []string + watchPath = append(watchPath, "/tmp/my_file") + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: watchPath, + Callback: func(path string) { + log.Println("file updated: ", path) + }, + }) + if err != nil { + log.Fatal(err) + } + defer watcher.Close() + // Block main goroutine forever. + <-make(chan struct{}) +} + +``` \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c516a5 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module github.com/sagernet/fswatch + +go 1.20 + +require ( + github.com/fsnotify/fsnotify v1.7.0 + github.com/sagernet/sing v0.4.1 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0803d1b --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sagernet/sing v0.4.1 h1:zVlpE+7k7AFoC2pv6ReqLf0PIHjihL/jsBl5k05PQFk= +github.com/sagernet/sing v0.4.1/go.mod h1:ieZHA/+Y9YZfXs2I3WtuwgyCZ6GPsIR7HdKb1SdEnls= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/watcher.go b/watcher.go new file mode 100644 index 0000000..325b656 --- /dev/null +++ b/watcher.go @@ -0,0 +1,136 @@ +package fswatch + +import ( + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/sagernet/sing/common" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/logger" + + "github.com/fsnotify/fsnotify" +) + +const DefaultWaitTimeout = 100 * time.Millisecond + +// Watcher is a fsnotify watcher to watch files correctly +type Watcher struct { + watchDirect bool + watchTarget []string + watchPath []string + callback func(path string) + waitTimeout time.Duration + logger logger.Logger + watcher *fsnotify.Watcher +} + +type Options struct { + // Path is the list of files or directories to watch + Path []string + + // Direct is the flag to watch the file directly if file will never be removed + Direct bool + + // Callback is the function to call when a file is updated + Callback func(path string) + + // WaitTimeout is the time to wait write events before calling the callback + // DefaultWaitTimeout is used by default + WaitTimeout time.Duration + + // Logger is the logger to log errors + // optional + Logger logger.Logger +} + +func NewWatcher(options Options) (*Watcher, error) { + if len(options.Path) == 0 || options.Callback == nil { + return nil, os.ErrInvalid + } + waitTimeout := options.WaitTimeout + if waitTimeout == 0 { + waitTimeout = DefaultWaitTimeout + } + var watchTarget []string + if options.Direct { + watchTarget = options.Path + } else { + watchTarget = common.Uniq(common.Map(options.Path, filepath.Dir)) + // TODO: update sing to use common.Remove when it's stable + watchTarget = common.Filter(watchTarget, func(it string) bool { + return !common.Any(watchTarget, func(path string) bool { + return len(path) > len(it) && strings.HasPrefix(path, it) + }) + }) + } + return &Watcher{ + watchDirect: options.Direct, + watchTarget: watchTarget, + watchPath: options.Path, + callback: options.Callback, + waitTimeout: waitTimeout, + logger: options.Logger, + }, nil +} + +func (w *Watcher) Start() error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return E.Cause(err, "fswatch: create fsnotify watcher") + } + for _, target := range w.watchTarget { + err = watcher.Add(target) + if err != nil { + return E.Cause(err, "fswatch: watch ", target) + } + } + w.watcher = watcher + go w.loopUpdate() + return nil +} + +func (w *Watcher) Close() error { + return w.watcher.Close() +} + +func (w *Watcher) loopUpdate() { + var timerAccess sync.Mutex + timerMap := make(map[string]*time.Timer) + for { + select { + case event, loaded := <-w.watcher.Events: + if !loaded { + return + } + if common.Contains(w.watchTarget, event.Name) && (event.Has(fsnotify.Rename) || event.Has(fsnotify.Remove)) { + if w.logger != nil { + w.logger.Error("fswatch: watcher removed: ", event.Name) + } + } else if common.Contains(w.watchPath, event.Name) && (event.Has(fsnotify.Create) || event.Has(fsnotify.Write)) { + timerAccess.Lock() + timer := timerMap[event.Name] + if timer != nil { + timer.Reset(w.waitTimeout) + } else { + timerMap[event.Name] = time.AfterFunc(w.waitTimeout, func() { + w.callback(event.Name) + timerAccess.Lock() + delete(timerMap, event.Name) + timerAccess.Unlock() + }) + } + timerAccess.Unlock() + } + case err, loaded := <-w.watcher.Errors: + if !loaded { + return + } + if w.logger != nil { + w.logger.Error("fswatch: ", err) + } + } + } +} diff --git a/watcher_test.go b/watcher_test.go new file mode 100644 index 0000000..86af6b1 --- /dev/null +++ b/watcher_test.go @@ -0,0 +1,109 @@ +package fswatch_test + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/sagernet/fswatch" + + "github.com/stretchr/testify/require" +) + +func TestFileWatcher(t *testing.T) { + t.Parallel() + tempDir, err := os.MkdirTemp("", "sing-box-file-watcher-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + watchPath := filepath.Join(tempDir, "test") + fileContent := "Hello world!" + done := make(chan struct{}) + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: []string{watchPath}, + Callback: func(path string) { + newContent, err := os.ReadFile(watchPath) + require.NoError(t, err) + require.Equal(t, fileContent, string(newContent)) + close(done) + }, + }) + require.NoError(t, err) + defer watcher.Close() + require.NoError(t, watcher.Start()) + file, err := os.Create(watchPath) + require.NoError(t, err) + _, err = file.WriteString(fileContent) + require.NoError(t, err) + require.NoError(t, file.Close()) + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("watch timeout") + } + done = make(chan struct{}) + require.NoError(t, os.Remove(watchPath)) + tempPath := filepath.Join(tempDir, "temp") + require.NoError(t, os.WriteFile(tempPath, []byte(fileContent), 0o644)) + require.NoError(t, os.Rename(tempPath, watchPath)) + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("watch timeout") + } + done = make(chan struct{}) + require.NoError(t, os.Remove(watchPath)) + require.NoError(t, os.WriteFile(tempPath, []byte(fileContent), 0o644)) + select { + case <-done: + t.Fatal("invalid event") + case <-time.After(1 * time.Second): + } +} + +func TestWatchDirect(t *testing.T) { + t.Parallel() + tempDir, err := os.MkdirTemp("", "sing-box-file-watcher-test") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + watchPath := filepath.Join(tempDir, "test") + file, err := os.Create(watchPath) + require.NoError(t, err) + _, err = file.WriteString("") + require.NoError(t, err) + require.NoError(t, file.Close()) + fileContent := "Hello world!" + done := make(chan struct{}) + watcher, err := fswatch.NewWatcher(fswatch.Options{ + Path: []string{watchPath}, + Direct: true, + Callback: func(path string) { + newContent, err := os.ReadFile(watchPath) + require.NoError(t, err) + require.Equal(t, fileContent, string(newContent)) + close(done) + }, + }) + require.NoError(t, err) + defer watcher.Close() + require.NoError(t, watcher.Start()) + file, err = os.Create(watchPath) + require.NoError(t, err) + _, err = file.WriteString(fileContent) + require.NoError(t, err) + require.NoError(t, file.Close()) + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("watch timeout") + } + done = make(chan struct{}) + tempPath := filepath.Join(tempDir, "temp") + require.NoError(t, os.WriteFile(tempPath, []byte(fileContent), 0o644)) + require.NoError(t, os.Rename(tempPath, watchPath)) + select { + case <-done: + t.Fatal("invalid event") + case <-time.After(1 * time.Second): + } +}