file_watcher
file_watcher
Cross-platform filesystem notifications for Crystal.
file_watcher uses the native operating-system facility where possible:
- macOS:
kqueue - Linux:
inotify - Windows:
ReadDirectoryChangesW - Other platforms, or failed native initialization: snapshot polling
Native backends deliver events with low latency by draining their OS queue on a short (~10 ms) cycle. The macOS backend pairs kqueue with a snapshot diff: kqueue provides the change wakeup, and the snapshot diff classifies it (including inode-based rename pairing). The polling fallback runs on poll_interval.
The public API is intentionally small: create a FileWatcher::Watcher, add one or more paths, consume its event stream, and close it when finished.
Installation
Add the dependency to shard.yml:
dependencies:
file_watcher:
github: naqvis/file_watcher
Then run:
shards install
Quick start
require "file_watcher"
watcher = FileWatcher.watcher # same as FileWatcher::Watcher.new
watcher.watch("/path/to/project") do |event|
if event.change?
case event.kind
in .created? then puts "Created: #{event.path}"
in .modified? then puts "Modified: #{event.path}"
in .deleted? then puts "Deleted: #{event.path}"
in .renamed? then puts "Renamed: #{event.old_path} -> #{event.path}"
in .metadata? then puts "Metadata: #{event.path}"
end
elsif event.error?
puts "Watcher error: #{event.exception.message}"
end
end
# Keep the application alive or do other work here.
# Call close during application shutdown.
watcher.close
The block receives both variants of the Event sum type: Event::Change for a filesystem change and Event::Error for a watcher/backend failure. The block runs in its own listener fiber. Separately, if your callback code itself raises an exception while handling an event, the exception is logged and isolated to that delivery. The listener remains active and receives subsequent events.
Channel API
Omit the block to receive the watcher's Channel(FileWatcher::Event):
watcher = FileWatcher::Watcher.new(
poll_interval: 250.milliseconds,
event_buffer: 512,
)
events = watcher.watch("src", "spec", recursive: true)
spawn do
while event = events.receive?
next unless event.change?
if event.kind.renamed?
puts "#{event.old_path} -> #{event.path}"
else
puts "#{event.kind}: #{event.path}"
end
end
end
watch also accepts an Array(String):
events = watcher.watch(["src", "spec"], recursive: false)
All paths registered on one Watcher feed one shared event channel. Calling watch again on the same watcher returns that same channel. Multiple consumers of the channel divide events between themselves; they are not independent broadcast subscribers. Create separate Watcher instances when independent consumers must each receive every event.
Events
Event has two concrete variants:
Event::Changeexposeskind,path, and optionalold_path.Event::Errorexposesexception.
Use event.change? or event.error? before reading variant-specific fields. Calling path, kind, or old_path on an error event—or exception on a change event—raises.
EventKind contains:
CreatedModifiedDeletedRenamedMetadata
Metadata reports attribute-only changes such as permission updates. It is emitted by the macOS, Linux, and polling backends; Windows reports these as Modified instead, so applications must not rely on receiving Metadata on every platform.
Paths are absolute, canonical paths when the entry exists. For rename events, path is the new path and old_path is the previous path.
Managing watches
Watches are recursive by default:
watcher.watch("/project") # descendants at every depth
watcher.watch("/project/assets", recursive: false) # direct children only
Registering the same canonical path again updates its recursive setting instead of adding a duplicate watch.
Use paths to inspect registered roots and unwatch to remove them:
pp watcher.paths
watcher.unwatch("/project/assets")
watcher.unwatch(["/project/src", "/project/spec"])
unwatch also works after the watched entry has been deleted. Removing the last path stops and closes the backend; adding another path starts it again.
close stops the worker, releases native handles, clears registered paths, and closes the event channel. It is safe to call more than once. Calling watch after close raises FileWatcher::ClosedError.
Buffering and slow consumers
The event channel is bounded (256 events by default). Backend polling never waits for a slow consumer: when the channel is full, new events are dropped and a warning is logged. This prevents filesystem activity from deadlocking the watcher or starving unrelated fibers.
Choose a larger buffer for bursty trees, and keep event handlers short. Offload expensive processing to another worker or queue:
watcher = FileWatcher::Watcher.new(event_buffer: 2_048)
Applications that cannot tolerate dropped events should treat notifications as invalidation signals and rescan their authoritative filesystem state.
Rename behavior
An in-tree move is emitted as Renamed, with the destination in path and the source in old_path. Native backends correlate the operating system's rename records; snapshot backends correlate filesystem identity.
A move out of the watched tree appears as Deleted. A move into the watched tree appears as Created. Filesystems and tools may generate additional events around atomic-save operations, so consumers should tolerate duplicate or coalesced notifications.
Backend and polling behavior
poll_interval defaults to 1.second and must be positive. It sets the cadence of the snapshot-polling fallback, the macOS safety rescan, and the window used to pair renames and flush unpaired move sources. It does not pace native event delivery.
Native backends drain their OS queue on a short internal cycle (about 10 ms), so events arrive quickly without blocking Crystal's single-threaded scheduler. The polling fallback scans the tree every poll_interval.
If native backend initialization fails, the watcher automatically switches to snapshot polling. Backend failures that occur later are delivered as Event::Error; the worker remains alive and retries on subsequent intervals. On Linux, an inotify queue overflow is also delivered as Event::Error so applications can rescan.
On macOS and Linux, recursive trees are watched natively: macOS opens one descriptor per explicit root, while Linux opens one inotify watch per directory (bounded by fs.inotify.max_user_watches; hitting that limit logs a warning for the affected subtree).
Watching a single file
Watching a file directly is supported, but watching its parent directory and filtering event.path is usually more reliable. Many editors save atomically by writing a temporary file and renaming it over the original, which replaces the watched filesystem object.
Threading and execution contexts
The watcher protects mutable state and backend lifecycle operations internally. Its fibers are created with Crystal's normal spawn, so they inherit the execution context from which watching is started. Event handlers should still avoid unsynchronized access to application-owned shared state.
Development
crystal spec
Contributing
- Fork it (https://github.com/naqvis/file_watcher/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
License
MIT
Contributors
- Ali Naqvi — creator and maintainer
file_watcher
- 0
- 0
- 0
- 0
- 0
- about 5 hours ago
- August 30, 2026
MIT License
Sun, 30 Aug 2026 07:12:25 GMT