FSEventStream Dispatch Queues: Move Callbacks Off Run Loops Safely
Adopt Apple's current dispatch-queue API for FSEventStream callbacks, handle startup failure, and tear streams down without racing callback state.
An FSEventStream delivers filesystem-change callbacks through a scheduling mechanism that belongs to the stream’s lifecycle. Older examples commonly attach a stream to a Core Foundation run loop. Apple’s current API reference marks FSEventStreamScheduleWithRunLoop deprecated and directs new code to FSEventStreamSetDispatchQueue. That change is not just a spelling substitution: the queue becomes part of the callback’s execution context, so ownership, callback latency, shutdown, and application work must be designed together.
This article focuses on the stream lifecycle and callback boundary. It does not treat FSEvents as a transaction log: notifications can be coalesced, and clients must still reconcile directory state when the API signals that a rescan is needed. See the related FSEvents overview for the event-ID and recovery model.
Give the stream an explicit callback executor
Create the stream with a callback and the roots it should watch, then associate it with one dispatch queue before starting it. Apple documents that FSEventStreamSetDispatchQueue schedules the stream on the specified queue; passing NULL removes it. The same reference says a scheduling problem is reported when the caller attempts to start the stream. Keep the queue alive for the stream’s active lifetime, and choose its concurrency intentionally.
A private serial queue is a useful default for an indexer that mutates one in-memory state machine from callbacks. It gives that application code a simple place to serialize short callback bookkeeping. It does not make filesystem scanning, database commits, or network operations cheap. Those tasks should be submitted to a separate worker or operation queue so a slow scan does not block later event delivery on the stream queue.
static bool start_stream(
dispatch_queue_t eventQueue,
FSEventStreamCallback callback,
const FSEventStreamContext *context,
CFArrayRef watchedRoots,
FSEventStreamEventId sinceEventID,
CFTimeInterval latency,
FSEventStreamCreateFlags streamFlags,
FSEventStreamRef *outStream)
{
if (eventQueue == NULL || outStream == NULL)
return false;
FSEventStreamRef stream = FSEventStreamCreate(
kCFAllocatorDefault, callback, context, watchedRoots,
sinceEventID, latency, streamFlags);
if (stream == NULL)
return false;
FSEventStreamSetDispatchQueue(stream, eventQueue);
if (!FSEventStreamStart(stream)) {
FSEventStreamInvalidate(stream);
FSEventStreamRelease(stream);
return false;
}
*outStream = stream;
return true;
}
The caller owns eventQueue and keeps it valid while the stream can use it; it also owns the returned stream reference. After a failed start or completed teardown, release the queue only when the watcher no longer owns or needs it. Dispatch-object ownership differs between ARC/Objective-C and manual-dispatch-reference configurations. In particular, do not call dispatch_release unconditionally in an ARC build, or release a queue while a stream or pending callback still depends on it. Do not copy the old run-loop schedule call and add a dispatch queue as a second scheduler. A stream should be managed through the scheduling mechanism selected for that stream. Treat a failed FSEventStreamStart as a startup failure, not as a partially healthy watcher; surface the error and do not report monitoring as active.
Keep callbacks short and preserve the data they need
The callback should decide what work to enqueue, copy the callback data it needs for later processing, and return. If work leaves the callback, do not retain raw path or flag pointers as if the API had transferred ownership to the application. Copy path strings, flags, and event IDs into an owned job record, then let a worker reconcile the affected paths against current filesystem state.
Use one clearly owned object for mutable index state. A serial event queue can update that object’s pending-work set; a separate worker can execute scans and send completion results back to the state owner. If multiple workers run concurrently, associate completions with explicit job generations or tokens so an older scan cannot overwrite the result of a newer invalidation. This is an application-level ordering rule, not a promise that callbacks encode each filesystem operation in order.
The callback’s context must also outlive every callback that can reference it. FSEventStreamContext lets a client supply retain and release callbacks for its info pointer. Use those hooks when they match the application’s ownership model, or keep the owner alive until the stream is invalidated and released. Do not let a callback enqueue a raw pointer to an owner that shutdown may free before the worker consumes the job.
Make startup and teardown explicit state transitions
Model the watcher as created, scheduled, running, stopping, and stopped, or an equivalent state machine. Publish running only after stream creation, queue association, and successful start. If any step fails, unwind the resources already acquired in reverse order. This prevents a UI or service health check from mistaking an allocated stream object for active monitoring.
For shutdown, first prevent new application work from being admitted, then stop the stream, invalidate it to remove its scheduling association, and release the stream when its owner is finished with it. Apple documents that FSEventStreamInvalidate removes the stream from its dispatch queue as well as invalidating it. Keep the callback context valid through this transition. If worker jobs were already submitted, choose a deliberate policy: drain and finish them, or cancel them and leave a durable “needs full reconciliation” marker for next startup. Dispose of the separately owned queue only after the stream is unscheduled and any application work that uses that queue has completed; use the dispatch-object ownership rules for the deployment target rather than assuming one release pattern fits every build.
void stopWatcher(struct watcher *watcher)
{
if (watcher->state != WatcherRunning)
return;
watcher->state = WatcherStopping; /* protected by the owner queue/lock */
close_job_admission(watcher);
FSEventStreamStop(watcher->stream);
FSEventStreamInvalidate(watcher->stream); /* also unschedules it */
FSEventStreamRelease(watcher->stream);
watcher->stream = NULL;
watcher->state = WatcherStopped;
}
Do not call this sketch unchanged from inside a callback if your shutdown path waits synchronously for the callback’s own serial queue; that can deadlock. Route stop requests to an owner executor that is distinct from the callback’s blocked execution context, or make the transition asynchronous. Likewise, do not assume that stopping the stream cancels work your callback already submitted to another queue; stream delivery and your application’s work queue have separate lifecycles.
Migrate run-loop examples without importing their assumptions
Run-loop scheduling ties delivery to a run loop that must actually be running in an appropriate mode. That may be correct for legacy code that intentionally integrates stream callbacks with a particular thread’s run-loop lifecycle, but it is easy to stall delivery when the loop is not serviced. The dispatch-queue API makes the callback executor explicit and avoids adding a run-loop source solely to receive FSEvents.
When migrating, remove the old schedule and unschedule calls for the stream being changed; do not leave both scheduling paths active. Move the callback’s shared-state assumptions to the selected queue or add synchronization. Audit code that used run-loop mode transitions to control when callbacks arrive: replacing the scheduler does not reproduce that policy automatically. If throttling is required, implement it at the application’s event-processing layer rather than assuming queue association is a delivery-rate limit.
Test lifecycle failures, not just event delivery
Use a temporary test directory and confirm that a stream reaches the running state, receives a change, and shuts down without accessing a released context. Inject stream-creation and start failures through a wrapper so each unwind path can be tested. Race a stop request with queued callback work, then assert that no job dereferences freed state and that restart either replays safely or performs the application’s normal reconciliation.
Also test a slow scan while new events arrive. The callback should remain short; pending paths may be coalesced by application policy, but a later invalidation must not be lost merely because an earlier worker is still running. Test both the worker-drain and cancel-with-reconcile shutdown policies if the product supports them. Record the macOS version, stream flags, executor type, lifecycle transitions, and whether jobs were drained or deferred. Avoid logging file paths by default when they can expose private data.
The core design is straightforward: one owner controls stream state, the dispatch queue defines callback execution, callbacks hand off owned data quickly, and shutdown keeps callback context alive until the stream is invalidated and released. That discipline makes the scheduler migration reviewable and makes races reproducible in tests rather than accidental in production.
Related:
- FSEvents on macOS: Persistent Change Journals, Event Coalescing, and Rescan Boundaries
- macOS Run Loops: Sources, Modes, Timers, and Thread Ownership
Sources:
- Apple Developer Documentation: FSEventStreamSetDispatchQueue
- Apple Developer Documentation: FSEventStreamScheduleWithRunLoop
- Apple Developer Documentation: FSEventStreamStart
- Apple Developer Documentation: FSEventStreamInvalidate
- Apple Developer Documentation: FSEventStreamCreate
- Apple Developer Documentation: FSEventStreamContext