diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a1bc7340..e11856758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Bug Fixes: - fix(docs): corrected stale and missing API reference links in usage.json metadata([#1803](https://github.com/fastly/cli/pull/1803)) +- fix(compute): `serve --watch` no longer rebuilds on attribute-only (Chmod) filesystem events, preventing an endless rebuild loop when another process changes a watched file's metadata such as its access time ([#1808](https://github.com/fastly/cli/pull/1808)) - fix(docs): expand and correct API reference links for `fastly service` subcommands in usage.json metadata ([#1810](https://github.com/fastly/cli/pull/1810)) ### Enhancements: diff --git a/pkg/commands/compute/serve.go b/pkg/commands/compute/serve.go index 66fe3f72e..7229c9fca 100644 --- a/pkg/commands/compute/serve.go +++ b/pkg/commands/compute/serve.go @@ -1295,6 +1295,10 @@ func watchFiles(root string, gi *ignore.GitIgnore, verbose bool, s *fstexec.Stre if !ok { return } + // Ignore attribute-only (Chmod) events; on macOS an atime bump alone would loop. + if !isContentChange(event.Op) { + continue + } debounced(func() { eventHandler(event.Name, event.Op) }) @@ -1393,6 +1397,15 @@ func readIgnoreFile(path string) (lines []string) { return strings.Split(string(bs), "\n") } +// isContentChange reports whether op is a content or existence change +// (Create/Write/Remove/Rename) and not an attribute-only Chmod. fsnotify +// recommends ignoring Chmod; see its "Why do I get many Chmod events?" FAQ: +// https://github.com/fsnotify/fsnotify#why-do-i-get-many-chmod-events +func isContentChange(op fsnotify.Op) bool { + return op.Has(fsnotify.Create) || op.Has(fsnotify.Write) || + op.Has(fsnotify.Remove) || op.Has(fsnotify.Rename) +} + func watchFile(path string, watcher *fsnotify.Watcher, verbose bool, out io.Writer) { absolute, err := filepath.Abs(path) if err != nil && verbose { diff --git a/pkg/commands/compute/serve_internal_test.go b/pkg/commands/compute/serve_internal_test.go new file mode 100644 index 000000000..e2007d64f --- /dev/null +++ b/pkg/commands/compute/serve_internal_test.go @@ -0,0 +1,32 @@ +package compute + +import ( + "testing" + + "github.com/fsnotify/fsnotify" +) + +// TestIsContentChange checks that content/existence events trigger a rebuild and +// attribute-only (Chmod) events do not. +func TestIsContentChange(t *testing.T) { + scenarios := []struct { + name string + op fsnotify.Op + want bool + }{ + {name: "create", op: fsnotify.Create, want: true}, + {name: "write", op: fsnotify.Write, want: true}, + {name: "remove", op: fsnotify.Remove, want: true}, + {name: "rename", op: fsnotify.Rename, want: true}, + {name: "chmod only", op: fsnotify.Chmod, want: false}, + {name: "write combined with chmod", op: fsnotify.Write | fsnotify.Chmod, want: true}, + {name: "no op", op: 0, want: false}, + } + for _, testcase := range scenarios { + t.Run(testcase.name, func(t *testing.T) { + if got := isContentChange(testcase.op); got != testcase.want { + t.Errorf("isContentChange(%s) = %v, want %v", testcase.op, got, testcase.want) + } + }) + } +}