#717·fsnotify

macOS watcher notifications miss writes to a deleted + re-created file

Author: prashantvCreated Oct 16, 2025Updated Mar 30, 2026
Labelsbugkqueue

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

go
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_DeleteRecreate

At 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