file_watcher

Cross-platform file watching for Crystal using native OS APIs

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::Change exposes kind, path, and optional old_path.
  • Event::Error exposes exception.

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:

  • Created
  • Modified
  • Deleted
  • Renamed
  • Metadata

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

  1. Fork it (https://github.com/naqvis/file_watcher/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

License

MIT

Contributors

Repository

file_watcher

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • about 5 hours ago
  • August 30, 2026
License

MIT License

Links
Synced at

Sun, 30 Aug 2026 07:12:25 GMT

Languages