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 indetail. - 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"}
]
endFsNotify 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 underkind: a bare atom (:file,:folder,:read,:any,:other) or a{group, sub}tuple ({:data, :content},{:metadata, :write_time},{:name, :both},{:open, :write},{:close, :read}). SeeFsNotify.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/detailfor the same operation across OSes.
Options
| Option | Default | Description |
|---|---|---|
:recursive | true | Watch subdirectories recursively. |
:debounce | 0 | Coalesce events over this many ms (notify-rs debouncer); 0 = off. |
:backend | :recommended | :recommended (OS-native) or :poll (portable polling). |
:poll_interval | 0 | Poll interval (ms) for the :poll backend; 0 = notify default. |
:subscriber | calling process | Process that receives {:fs_notify_event, ...} messages. |
How it works
notify-rs thread --{:fs_notify_event, %Event{}}--> subscriber processThe 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:
- Bump the version in
mix.exsandnative/fs_notify/Cargo.toml(keep them in sync). - Update
CHANGELOG.md. - Commit and push to
main. - 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.