Prerequisites
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
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 |
Prerequisites
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_innerstops the single shared stream before mutating the path list, andrun()recreates it at
since_when: kFSEventStreamEventIdSinceNow, which is set once in theconstructor 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_innerhas thesame shape.
In 8.2.0 the runloop thread's exit path also calls
FSEventsPurgeEventsForDeviceUpToEventId(device, FSEventsGetCurrentEventId()), which makesthe 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
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 intoa,sleeps for a gap, then optionally registers a second, unrelated directory
b, thenwaits up to 10 seconds for the event about
a.Registering
bis the only difference between the two columns — nothing in the program everwrites to, reads from, or otherwise touches
b. The 10s deadline is deliberately generousso that a miss means the event was destroyed rather than merely slow.
Actual
macOS 26.5.1, arm64, otherwise idle, 20 trials per cell:
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 a3-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:
FSEventStreamEventIdand pass it assince_whenon restartinstead of
kFSEventStreamEventIdSinceNow, so the new stream replays what the old one hadnot delivered. This risks re-delivering events the caller already saw, which may be
acceptable for a mutation-notification API but is a semantic change.
update_paths_inneralreadyreduces 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
agent_identitysessionagent_personaagent_supervisoragent_toolagent_tool_versionagent_runtimetooling_profile