Hex.pm HexDocs License CI

Cross-platform filesystem watcher for Elixir, backed by notify-rs via Rustler.

Watch files and directories and receive change events as messages in your own process. Each native watcher runs on its own OS-backed thread (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows) and forwards events into the BEAM.

Features

  • Native, low-overhead watching via notify-rs (no polling).
  • Each event is delivered straight to a subscriber process as {:fs_notify_event, %FsNotify.Event{}} — one message per event.
  • Watch one path or many with a single watcher; recursive or not.
  • Fine-grained event kinds (:create, :modify, :remove, :access, :other) with the notify subtype preserved in detail.
  • Automatic cleanup: the OS watch stops when the subscriber process dies (or the returned reference is garbage-collected) — no leaks, even on crash.

Installation

Add fs_notify to your deps in mix.exs:

def deps do
  [
    {:fs_notify, "~> 0.1.0"}
  ]
end

FsNotify ships precompiled NIFs via rustler_precompiled, so a Rust toolchain is not required for the supported targets (macOS / Linux / Windows on x86_64 and aarch64). The artifact for your platform is downloaded and verified against a checksum at compile time.

Building from source

To compile the NIF locally instead of downloading it (e.g. an unsupported target, or to hack on the Rust code), force a build — this needs cargo:

FS_NOTIFY_BUILD=1 mix compile

You can also force a build for all rustler_precompiled packages with config :rustler_precompiled, force_build_all: true. Non-:prod Mix environments build from source by default.

Quick start

Watch from a process and handle events in handle_info/2:

defmodule DirWatcher do
  use GenServer

  def start_link(dir), do: GenServer.start_link(__MODULE__, dir)

  @impl true
  def init(dir) do
    {:ok, _ref} = FsNotify.watch(dir, recursive: true)
    {:ok, dir}
  end

  @impl true
  def handle_info({:fs_notify_event, %FsNotify.Event{kind: kind, paths: paths}}, dir) do
    IO.puts("#{kind}: #{Enum.join(paths, ", ")}")
    {:noreply, dir}
  end
end

{:ok, _pid} = DirWatcher.start_link("/path/to/dir")

The watch stops automatically when DirWatcher stops — no cleanup needed.

Usage

# Start watching a directory (or a list of paths). Events go to the caller by default.
{:ok, ref} = FsNotify.watch("/path/to/dir", recursive: true)

# A single watcher can cover several paths:
{:ok, ref} = FsNotify.watch(["/path/a", "/path/b"])

# Receive events — one message per event, carrying all affected paths.
receive do
  {:fs_notify_event, %FsNotify.Event{kind: kind, paths: paths}} ->
    IO.inspect({kind, paths})
end

# Stop watching. Optional — the native watch also stops automatically when the
# subscriber process dies or `ref` is garbage-collected.
FsNotify.unwatch(ref)

Keep ref for as long as you want to watch; the watch is tied to the subscriber process.

Message shape

Each notify event arrives as its own message:

{:fs_notify_event, %FsNotify.Event{kind: kind, detail: detail, paths: paths}}
  • kind — top-level category: :any | :access | :create | :modify | :remove | :other.

  • detail — notify sub-kind under kind: a bare atom (:file, :folder, :read, :any, :other) or a {group, sub} tuple ({:data, :content}, {:metadata, :write_time}, {:name, :both}, {:open, :write}, {:close, :read}). See FsNotify.Event.
  • paths — affected paths as binaries; a reconciled rename carries [from, to].

notify reports different event kinds per platform, so do not rely on a specific kind/detail for the same operation across OSes.

Options

OptionDefaultDescription
:recursivetrueWatch subdirectories recursively.
:debounce0Coalesce events over this many ms (notify-rs debouncer); 0 = off.
:backend:recommended:recommended (OS-native) or :poll (portable polling).
:poll_interval0Poll interval (ms) for the :poll backend; 0 = notify default.
:subscribercalling processProcess that receives {:fs_notify_event, ...} messages.

How it works

notify-rs thread  --{:fs_notify_event, %Event{}}-->  subscriber process

The native RecommendedWatcher is held inside a Rustler resource. Events are mapped to %FsNotify.Event{} in Rust and pushed directly to the subscriber, one message per event. With :debounce set, events are coalesced natively via notify-debouncer-full. The resource monitors the subscriber: when it dies (or when the resource is garbage-collected), notify is dropped and the OS watch stops. There is no GenServer in the path — no event-kind filtering or supervision; do that in your own process if you need it.

Development

mix deps.get
mix test          # runs unit + native integration tests (uses real temp dirs)
mix format
mix docs

Releasing

Publishing a GitHub release triggers .github/workflows/release.yml, which cross-compiles the NIFs, attaches them to the release, generates the rustler_precompiled checksum file, and runs mix hex.publish.

Required steps:

  1. Bump the version in mix.exs and native/fs_notify/Cargo.toml (keep them in sync).
  2. Update CHANGELOG.md.
  3. Commit and push to main.
  4. Create the release: gh release create v<version> --generate-notes.

Pushes to main and PRs touching native/** build the targets too (no upload, no publish) to catch cross-compile breakage early.

License

MIT