macOS watcher notifications miss writes to a deleted + re-created file
Describe the bug
When a deleted file is re-created with some content, a watcher on the directory is expected to (eventually) notify after the file has the new content available for read.
On Linux, we consistently see REMOVE, CREATE, and WRITE notifications.
On macOS, we see REMOVE + CREATE consistently, and sometimes a WRITE notification. If the file is read on CREATE/WRITE, usually the content is available at CREATE time, and if not, then at WRITE. However, there are cases where there is no WRITE notification, and at CREATE time, the content is not available.
This results in cases where the caller (which reads a file on event) never sees an event followed by the content being available on macOS.
Code to Reproduce
package main
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/fsnotify/fsnotify"
"github.com/stretchr/testify/require"
)
func TestFileWatcher_DeleteRecreate(t *testing.T) {
dir := filepath.Join(t.TempDir(), "cfgDir")
require.NoError(t, os.MkdirAll(dir, 0o755))
filename := filepath.Join(dir, "config.yaml")
// Reuse the same dir/file for multiple tests to speed up the repro.
const retries = 1000
for i := range retries {
t.Log("iteration", i)
require.NoError(t, os.WriteFile(filename, []byte("initial"), 0o644))
runTest(t, dir, filename)
if t.Failed() {
return
}
}
}
// The test watches a directory with file config.yaml, initially with value "initial".
// Once the watcher is created and setup, the file is removed, then recreated with value "recreated".
// The expectation is that the watcher eventually sees the new "recreated" value when reading the file on a watcher event.
func runTest(t testing.TB, dir, filename string) {
watcher, err := fsnotify.NewWatcher()
require.NoError(t, err)
defer watcher.Close()
require.NoError(t, watcher.Add(dir))
gotContent := make(chan string, 10)
wg := newStoppableWG()
defer wg.Stop()
wg.Go(func(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case err, ok := <-watcher.Errors:
if !ok {
t.Log("fsnotify.Watcher.Errors channel closed")
return
}
if err == nil {
t.Fatal("fsnotify.Watcher.Errors returned a nil error")
} else {
t.Fatal("watcher got error:", err)
}
case ev, ok := <-watcher.Events:
if !ok {
t.Log("fsnotify.Watcher.Events channel closed")
return
}
t.Log("fsnotify.Watcher event", ev.Op, filepath.Base(ev.Name))
content, err := os.ReadFile(filename)
if err != nil {
if os.IsNotExist(err) && ev.Op == fsnotify.Remove {
// REMOVE notification followed by not-found error is expected.
continue
}
t.Log("ReadFile failed: ", err)
continue
}
t.Logf("Read file content: %q", content)
gotContent <- string(content)
}
}
})
require.NoError(t, os.Remove(filename))
require.NoError(t, os.WriteFile(filename, []byte("recreated"), 0o644))
for {
select {
case content := <-gotContent:
if content == "recreated" {
t.Log("Got expected content, DONE")
return
}
case <-time.After(3 * time.Second):
require.Fail(t, "timed out waiting for content")
}
}
}
type stoppableWG struct {
sync.WaitGroup
bgCtx context.Context
bgCancel context.CancelFunc
}
func newStoppableWG() *stoppableWG {
bgCtx, bgCancel := context.WithCancel(context.Background())
return &stoppableWG{
bgCtx: bgCtx,
bgCancel: bgCancel,
}
}
func (wg *stoppableWG) Go(fn func(ctx context.Context)) {
wg.WaitGroup.Go(func() {
fn(wg.bgCtx)
})
}
func (wg *stoppableWG) Stop() {
wg.bgCancel()
wg.WaitGroup.Wait()
}File operations to reproduce
Run the above test many times, e.g., go test -v -count 100 -failfast.
On Linux, this passes consistently. On macOS, it eventually fails with an error like:
go get github.com/fsnotify/fsnotify@main
repro_test.go:68: fsnotify.Watcher event REMOVE config.yaml
repro_test.go:79: Read file content: ""
repro_test.go:68: fsnotify.Watcher event CREATE config.yaml
repro_test.go:79: Read file content: ""
repro_test.go:96:
Error Trace: [...]/repro_test.go:96
[...]/repro_test.go:27
Error: timed out waiting for content
Test: TestFileWatcher_DeleteRecreateAt the time of the CREATE notification, the written data was not available. There was no following WRITE notification causing the test to time out.
Which operating system and version are you using?
Darwin 24.6.0 Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:29 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6000 arm64
Which fsnotify version are you using?
v1.9.0
Did you try the latest main branch?
Yes
Source: fsnotify/fsnotify