From 5f97e1d495dd341f2bf573d80b0edb4d02a78a7b Mon Sep 17 00:00:00 2001 From: Shulhan Date: Sun, 15 Dec 2024 00:22:14 +0700 Subject: watchfs/v2: implement new file watcher The watchfs package contains new FileWatcher, more simple than what we have in [memfs.Watcher]. --- README.md | 5 + lib/watchfs/v2/file_watcher.go | 85 +++++++++++++ lib/watchfs/v2/file_watcher_example_test.go | 59 +++++++++ lib/watchfs/v2/file_watcher_options.go | 15 +++ lib/watchfs/v2/file_watcher_test.go | 182 ++++++++++++++++++++++++++++ lib/watchfs/v2/watchfs.go | 5 + 6 files changed, 351 insertions(+) create mode 100644 lib/watchfs/v2/file_watcher.go create mode 100644 lib/watchfs/v2/file_watcher_example_test.go create mode 100644 lib/watchfs/v2/file_watcher_options.go create mode 100644 lib/watchfs/v2/file_watcher_test.go create mode 100644 lib/watchfs/v2/watchfs.go diff --git a/README.md b/README.md index 713dd8c1..b9adad4f 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,11 @@ A library for working with time. Package totp implement Time-Based One-Time Password Algorithm based on RFC 6238. +[**watchfs/v2**](https://pkg.go.dev/git.sr.ht/~shulhan/pakakeh.go/lib/watchfs/v2):: +Package watchfs implement naive file watcher. +The version 2 simplify watching single file and directory. +For directory watcher, it watch only one file instead of all included files. + [**websocket**](https://pkg.go.dev/git.sr.ht/~shulhan/pakakeh.go/lib/websocket):: The WebSocket library for server and client. This WebSocket library has been tested with autobahn testsuite with 100% success rate. diff --git a/lib/watchfs/v2/file_watcher.go b/lib/watchfs/v2/file_watcher.go new file mode 100644 index 00000000..c66a7882 --- /dev/null +++ b/lib/watchfs/v2/file_watcher.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// SPDX-License-Identifier: BSD-3-Clause + +package watchfs + +import ( + "errors" + "os" + "time" +) + +// FileWatcher watch a single file. +// It will send the [os.FileInfo] to the channel C when the file is created, +// updated; or nil if file has been deleted. +// +// The FileWatcher may stop unexpectedly when the [os.Stat] return an +// error other than [os.ErrNotExist]. +// The last error can be inspected using [FileWatcher.Err]. +type FileWatcher struct { + fstat os.FileInfo + err error + + // C receive new file information. + C <-chan os.FileInfo + c chan os.FileInfo + + ticker *time.Ticker + opts FileWatcherOptions +} + +// WatchFile watch the file [watchfs.FileWatcherOptions.File] for being +// created, updated, or deleted; on every +// [watchfs.FileWatcherOptions.Interval]. +func WatchFile(opts FileWatcherOptions) (fwatch *FileWatcher) { + fwatch = &FileWatcher{ + c: make(chan os.FileInfo, 1), + ticker: time.NewTicker(opts.Interval), + opts: opts, + } + fwatch.fstat, fwatch.err = os.Stat(opts.File) + fwatch.C = fwatch.c + go fwatch.watch() + return fwatch +} + +// Err return the last error that cause the watch stopped. +func (fwatch *FileWatcher) Err() error { + return fwatch.err +} + +// Stop watching the file. +func (fwatch *FileWatcher) Stop() { + fwatch.ticker.Stop() + close(fwatch.c) +} + +func (fwatch *FileWatcher) watch() { + var newStat os.FileInfo + for range fwatch.ticker.C { + newStat, fwatch.err = os.Stat(fwatch.opts.File) + if fwatch.err != nil { + if errors.Is(fwatch.err, os.ErrNotExist) { + if fwatch.fstat != nil { + // File deleted. + fwatch.fstat = nil + fwatch.c <- nil + } + continue + } + // Other errors cause the watcher stop unexpectedly. + fwatch.Stop() + return + } + if fwatch.fstat != nil { + if newStat.Mode() == fwatch.fstat.Mode() && + newStat.ModTime().Equal(fwatch.fstat.ModTime()) && + newStat.Size() == fwatch.fstat.Size() { + continue + } + } + // File created or updated. + fwatch.fstat = newStat + fwatch.c <- newStat + } +} diff --git a/lib/watchfs/v2/file_watcher_example_test.go b/lib/watchfs/v2/file_watcher_example_test.go new file mode 100644 index 00000000..06672eb9 --- /dev/null +++ b/lib/watchfs/v2/file_watcher_example_test.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// SPDX-License-Identifier: BSD-3-Clause + +package watchfs_test + +import ( + "fmt" + "log" + "os" + "path/filepath" + "time" + + "git.sr.ht/~shulhan/pakakeh.go/lib/watchfs/v2" +) + +func ExampleWatchFile() { + var ( + name = `file.txt` + opts = watchfs.FileWatcherOptions{ + File: filepath.Join(os.TempDir(), name), + Interval: 50 * time.Millisecond, + } + ) + + fwatch := watchfs.WatchFile(opts) + + // On create ... + _, err := os.Create(opts.File) + if err != nil { + log.Fatal(err) + } + + var fi os.FileInfo = <-fwatch.C + fmt.Printf("file %q created\n", fi.Name()) + + // On update ... + err = os.WriteFile(opts.File, nil, 0600) + if err != nil { + log.Fatal(err) + } + fi = <-fwatch.C + fmt.Printf("file %q updated\n", fi.Name()) + + // On delete ... + err = os.Remove(opts.File) + if err != nil { + log.Fatal(err) + } + + fi = <-fwatch.C + fmt.Printf("file deleted: %v\n", fi) + + fwatch.Stop() + + // Output: + // file "file.txt" created + // file "file.txt" updated + // file deleted: +} diff --git a/lib/watchfs/v2/file_watcher_options.go b/lib/watchfs/v2/file_watcher_options.go new file mode 100644 index 00000000..ca9c0834 --- /dev/null +++ b/lib/watchfs/v2/file_watcher_options.go @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// SPDX-License-Identifier: BSD-3-Clause + +package watchfs + +import "time" + +// FileWatcherOptions define the options to watch file. +type FileWatcherOptions struct { + // Path to the file to be watched. + File string + + // Interval to check for file changes. + Interval time.Duration +} diff --git a/lib/watchfs/v2/file_watcher_test.go b/lib/watchfs/v2/file_watcher_test.go new file mode 100644 index 00000000..7c4fa7fe --- /dev/null +++ b/lib/watchfs/v2/file_watcher_test.go @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// SPDX-License-Identifier: BSD-3-Clause + +package watchfs_test + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "git.sr.ht/~shulhan/pakakeh.go/lib/reflect" + "git.sr.ht/~shulhan/pakakeh.go/lib/test" + "git.sr.ht/~shulhan/pakakeh.go/lib/watchfs/v2" +) + +type directFileInfo struct { + modTime time.Time + reflect.Equaler + name string + size int64 + mode os.FileMode + isDir bool +} + +func newDirectFileInfo(fi os.FileInfo) (directfi *directFileInfo) { + if fi == nil { + return nil + } + directfi = &directFileInfo{ + name: fi.Name(), + size: fi.Size(), + mode: fi.Mode(), + modTime: fi.ModTime().Truncate(time.Second), + isDir: fi.IsDir(), + } + return directfi +} + +func (directfi directFileInfo) Equal(v any) (err error) { + var ( + other *directFileInfo + ok bool + ) + other, ok = v.(*directFileInfo) + if !ok { + return fmt.Errorf(`expecting type %T, got %T`, other, v) + } + + if directfi.name != other.name { + return fmt.Errorf(`name: got %s, want %s`, + other.name, directfi.name) + } + if directfi.size != other.size { + return fmt.Errorf(`size: got %d, want %d`, + other.size, directfi.size) + } + if directfi.mode != other.mode { + return fmt.Errorf(`filemode: got %d, want %d`, + other.mode, directfi.mode) + } + if directfi.modTime.IsZero() { + directfi.modTime = other.modTime + } else if directfi.modTime.After(other.modTime) { + return fmt.Errorf(`modTime: got %v, want %v`, + other.modTime, directfi.modTime) + } + if directfi.isDir != other.isDir { + return fmt.Errorf(`isDir: got %t, want %t`, + other.isDir, directfi.isDir) + } + return nil +} + +func TestWatchFile(t *testing.T) { + var ( + name = `file.txt` + opts = watchfs.FileWatcherOptions{ + File: filepath.Join(t.TempDir(), name), + Interval: 50 * time.Millisecond, + } + + fwatch = watchfs.WatchFile(opts) + expfi = &directFileInfo{ + name: name, + size: 0, + mode: 420, + } + + file *os.File + err error + ) + + t.Run(`On created`, func(t *testing.T) { + file, err = os.Create(opts.File) + if err != nil { + t.Fatal(err) + } + + // It should trigger an event. + var fi os.FileInfo = <-fwatch.C + var gotfi = newDirectFileInfo(fi) + test.Assert(t, `fwatch.C`, expfi, gotfi) + + fi, err = file.Stat() + if err != nil { + t.Fatal(err) + } + var orgfi = newDirectFileInfo(fi) + test.Assert(t, `created `+opts.File+` on FileInfo`, + orgfi, gotfi) + + expfi.modTime = orgfi.modTime + }) + + t.Run(`On update content`, func(t *testing.T) { + var expBody = `update` + _, err = file.WriteString(expBody) + if err != nil { + t.Fatal(err) + } + err = file.Sync() + if err != nil { + t.Fatal(err) + } + + var fi os.FileInfo = <-fwatch.C + var gotfi = newDirectFileInfo(fi) + expfi.size = 6 + test.Assert(t, `fwatch.C`, expfi, gotfi) + + var gotBody []byte + gotBody, err = os.ReadFile(opts.File) + if err != nil { + t.Fatal(err) + } + test.Assert(t, `body`, expBody, string(gotBody)) + + expfi.modTime = gotfi.modTime + }) + + t.Run(`On update mode`, func(t *testing.T) { + var expMode = os.FileMode(0750) + err = file.Chmod(expMode) + if err != nil { + t.Fatal(err) + } + + var fi os.FileInfo = <-fwatch.C + var gotfi = newDirectFileInfo(fi) + expfi.mode = expMode + test.Assert(t, `fwatch.C`, expfi, gotfi) + + expfi.modTime = gotfi.modTime + }) + + t.Run(`On deleted`, func(t *testing.T) { + err = file.Close() + if err != nil { + t.Fatal(err) + } + err = os.Remove(opts.File) + if err != nil { + t.Fatal(err) + } + + var fi os.FileInfo = <-fwatch.C + var gotfi = newDirectFileInfo(fi) + var nilfi *directFileInfo + test.Assert(t, `fwatch.C`, nilfi, gotfi) + + fwatch.Stop() + + var expError = `no such file or directory` + var gotError = fwatch.Err().Error() + if !strings.Contains(gotError, expError) { + t.Fatalf(`error does not contains %q`, expError) + } + }) +} diff --git a/lib/watchfs/v2/watchfs.go b/lib/watchfs/v2/watchfs.go new file mode 100644 index 00000000..f93c1914 --- /dev/null +++ b/lib/watchfs/v2/watchfs.go @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: 2024 M. Shulhan +// SPDX-License-Identifier: BSD-3-Clause + +// Package watchfs implement naive file watcher. +package watchfs -- cgit v1.3