Skip to content

[Bug]: fsevent: registering a watch drops events already queued for the paths already being watched #993

Description

@schickling-assistant

Prerequisites

  • I searched existing issues to avoid duplicates

Component

notify (core library)

Operating System

macOS

Notify Version

8.2.0, and 9.0.0-rc.4

Notify Backend

FSEvents (macOS)

Bug Report

What happened?

On the FSEvents backend, adding a watch destroys events that were already queued for
directories that were not being added and never left the watch set. The event is not
delivered late; it is never delivered.

watch_inner stops the single shared stream before mutating the path list, and run()
recreates it at since_when: kFSEventStreamEventIdSinceNow, which is set once in the
constructor and never advanced. Anything the old stream had produced but not yet delivered
is gone — for every watched path, not just the one being added. unwatch_inner has the
same shape.

In 8.2.0 the runloop thread's exit path also calls
FSEventsPurgeEventsForDeviceUpToEventId(device, FSEventsGetCurrentEventId()), which makes
the discard explicit. That call is gone in 9.0.0-rc.4, but the loss is not: the stream is
still torn down and recreated at SinceNow, and rc.4 loses events at the same rate below.

By contrast the inotify backend adds a watch descriptor to a shared fd, and its queue
survives.

This is the same code path as #988, but that issue is about cost (adding N paths is
O(N²)); this is about correctness. #937 is an adjacent macOS/CI symptom but a different
one. As far as I can find, neither reports that a registration change loses already-queued
events, which is why I am filing separately rather than commenting on #988.

Steps to reproduce

https://github.com/schickling-repros/2026-08-notify-fsevent-watch-drops-queued-events

cargo run --release

The repro links both 8.2.0 and 9.0.0-rc.4 at once, so a single run compares them. Each
trial watches directory a, sleeps 500ms so the backend is settled, writes a file into a,
sleeps for a gap, then optionally registers a second, unrelated directory b, then
waits up to 10 seconds for the event about a.

Registering b is the only difference between the two columns — nothing in the program ever
writes to, reads from, or otherwise touches b. The 10s deadline is deliberately generous
so that a miss means the event was destroyed rather than merely slow.

Actual

macOS 26.5.1, arm64, otherwise idle, 20 trials per cell:

notify 8.2.0 (latest stable)
  gap=  0ms   control  20/20   second-directory-registered  20/20
  gap=  1ms   control  20/20   second-directory-registered  20/20
  gap=  5ms   control  20/20   second-directory-registered  18/20
  gap= 20ms   control  20/20   second-directory-registered  20/20
  gap=100ms   control  20/20   second-directory-registered  20/20
  TOTAL         control 100/100   second-directory-registered  98/100

notify 9.0.0-rc.4 (next major, release candidate)
  gap=  0ms   control  20/20   second-directory-registered  20/20
  gap=  1ms   control  20/20   second-directory-registered  20/20
  gap=  5ms   control  20/20   second-directory-registered  18/20
  gap= 20ms   control  20/20   second-directory-registered  20/20
  gap=100ms   control  20/20   second-directory-registered  20/20
  TOTAL         control 100/100   second-directory-registered  98/100

The control column never loses an event; only the column where the unrelated watch(b)
happens does. Which gap bucket the losses land in varies between runs — at 2 losses per 100
that is noise and nothing should be read into it.

The same program on Linux (inotify) delivers 100/100 in both columns, on both versions.

This is timing sensitive. On an idle, many-core machine the backend usually wins the
race within a millisecond or two, so the loss is rare. It is much easier to hit on a loaded
or low-core machine, where the callback thread is descheduled for longer than the caller
takes to reach its next watch() call. I first met this as a test that failed only on a
3-core hosted macOS CI runner and never on a developer's Mac.

Expected

Registering or removing a watch should not discard events already produced for paths that
were being watched both before and after the call.

Notes

I do not have a fix to offer, only the measurement. Two directions seem plausible from
reading the backend, and I may be wrong about both:

  • Track the last delivered FSEventStreamEventId and pass it as since_when on restart
    instead of kFSEventStreamEventIdSinceNow, so the new stream replays what the old one had
    not delivered. This risks re-delivering events the caller already saw, which may be
    acceptable for a mutation-notification API but is a semantic change.
  • Avoid the restart entirely for the common case. rc.4's update_paths_inner already
    reduces the number of restarts by batching, which shrinks the exposure without closing it.

A workaround for callers, in case it helps anyone finding this issue later: treat a change to
the registration set the same way you would treat a backend error — re-read the state of
everything you are watching, and suppress a notification when the state is unchanged.

Posted on behalf of @schickling
field value
agent_identity dev3.direct.claude.paqjmjfq
session dev3.paqjmjfq
agent_persona generalist
agent_supervisor unavailable
agent_tool Claude Code
agent_tool_version 2.1.250
agent_runtime Claude Code 2.1.250
tooling_profile dotfiles@a1a5f89

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions