fswatch

Cross-platform filesystem watcher for MoonBit: FSEvents on macOS, inotify on Linux, polling fallback elsewhere.

moonbit
filesystem
watch
fswatch
fsevents
inotify
moon add mizchi/fswatch@0.2.1
Download zip
Author
Version
0.2.1
License
Apache-2.0
Last updated
last month
Downloads
648

Dependencies

README

#mizchi/fswatch

Cross-platform filesystem watcher for MoonBit.

@fswatch.start(roots) returns a Watcher that emits Event { path, kind: Created | Modified | Removed } for changes under each root. The backend is selected at runtime:

Target / OSBackendLatency
native / macOSFSEvents (via dlopen, no -framework link dep)~50 ms drain interval, OS-driven detection
native / Linuxinotify~50 ms drain interval, kernel-driven detection
native / WindowsReadDirectoryChangesW (per-root HANDLE + OVERLAPPED, single reader thread via WaitForMultipleObjects)~50 ms drain interval, kernel-driven detection
native / otherPolling (mtime + size fingerprint)~500 ms scan interval
js / Nodefs.watch per root with recursive: true~50 ms drain interval, Node event-loop-driven detection

All backends share a single normalization layer (watch_normalize.mbt): OS-canonical paths (e.g. /private/tmp/... on macOS) are rewritten back to the user-supplied root form, and Created vs Modified is resolved against an initial directory walk so flag-bit quirks don't cross the API surface.

#Usage

async fn watch_src() -> Unit raise {
let watcher = @fswatch.start(["src", "Taskfile.pkl"])
defer watcher.close()
for ;; {
let events = watcher.next()
if events.length() == 0 { break }
for e in events {
println("\{e.kind} \{e.path}")
}
}
}

#Options

  • interval_ms?: drain cadence in ms. Default 50 (native) / 500 (polling).
  • exclude?: (String) -> Bool. Returning true drops the entry — for directories, descendants are pruned too.
  • start_polling(...): force the polling backend (deterministic, lets tests bypass platform quirks).

#Limitations

  • Linux + Windows native runtime is validated only via CI (developing on darwin). The first CI run after the relevant commits is the first time inotify / RDCW execute end-to-end.
  • inotify's per-user watch limit (/proc/sys/fs/inotify/max_user_watches) caps deeply-recursive trees.
  • RDCW caps at 63 simultaneous roots per watcher (MAXIMUM_WAIT_OBJECTS - 1 for the stop event). Most projects use 1–3 roots so this is moot in practice.
  • Node fs.watch with recursive: true requires Node 20+ on Linux. On older versions only the top-level directory is observed.
  • File paths containing literal newlines are not supported on the js backend (the FFI boundary uses '\n' as a separator).
  • No event-time wakeup; the drain loop polls at interval_ms. Sub-50ms latency would need a pipe-wake bridge from the reader thread (or fs.watch listener) into the MoonBit async runtime.

#
Event

pub(all) struct Event {
path : String
kind : EventKind
}

impl Show for Event

#
EventKind

pub(all) enum EventKind {
Created
Modified
Removed
} derive(Eq)

Cross-target filesystem watcher event types. The concrete Watcher struct and its start / next / close methods are defined per target — native dispatches across FSEvents (macOS) / inotify (Linux) / ReadDirectoryChangesW (Windows) / polling; js uses Node fs.watch.
impl Show for EventKind

#
Watcher

pub struct Watcher {
// private fields
}

A filesystem watcher. Calls to next block until at least one change is observed, then return the batch of events seen during that scan.

The watcher transparently picks the fastest available backend at start: FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows, polling fallback elsewhere. The public surface is identical across backends.

#
Watcher::close

fn Watcher::close(self : Watcher) -> Unit

Mark the watcher closed and release backend resources. Idempotent.

#
Watcher::next

async fn Watcher::next(self : Watcher) -> Array[Event]

Block until at least one event is observed (or the watcher is closed), then return all events from that scan. Returns [] only on close.

#
start

async fn start(roots : Array[String], interval_ms? : Int, exclude? : (String) -> Bool) -> Watcher

Start a watcher rooted at roots. Each entry must be a directory; paths are tracked recursively. interval_ms controls how often next re-checks for events (default 500 ms for polling, 50 ms for FSEvents/inotify). exclude is applied per path; returning true drops both the entry and (for directories) its descendants from tracking.

#
start_polling

async fn start_polling(roots : Array[String], interval_ms? : Int, exclude? : (String) -> Bool) -> Watcher

Force the polling backend, ignoring FSEvents/inotify/etc. Useful for tests that need deterministic snapshot semantics, or for environments where the native backend misbehaves.