Cruise
A fast, OS-native file watcher for Ruby.
A file system watcher built on native OS events, not polling.
Uses FSEvents on macOS and inotify on Linux.
Cruise was originally built to power file watching in the Herb dev server, none of the existing Ruby watchers were quite reliable enough for the realtime hot reloading that a local development server and workflow needs. It also works standalone for any project that needs to react to file changes.
Installation
Add to your Gemfile:
gem "cruise"
Or install directly:
gem install cruise
Precompiled native gems are available for macOS and Linux. If a precompiled gem isn't available for your platform, it will compile from source (requires Rust).
Usage
Basic Example
Watch a directory for changes:
require "cruise"
Cruise.watch("app/views") do |event|
puts "#{event.kind}: #{event.path}"
end
The callback receives a Cruise::Event with two attributes:
| Attribute | Description |
|---|---|
event.path |
Absolute path to the changed file |
event.kind |
One of: "created", "modified", "renamed", "removed", "accessed", "changed" |
Multiple Directories
Watch several paths at once:
Cruise.watch("app/views", "app/components", "lib/templates") do |event|
puts event.inspect
# => #<Cruise::Event kind="modified" path="/app/views/users/show.html.erb">
end
Ruby Threads
Cruise releases the GVL while waiting for filesystem events, so Ruby threads run freely:
Thread.new { do_background_work }
Cruise.watch("src") do |event|
puts event
end
Glob Filtering
Only receive events for files matching a pattern:
Cruise.watch("app/views", glob: "**/*.html.erb") do |event|
puts event
end
Multiple patterns:
Cruise.watch("app", glob: ["**/*.html.erb", "**/*.html"]) do |event|
puts event
end
Debounce
Configure the debounce interval (default: 100ms):
Cruise.watch("src", debounce: 0.5) do |event|
puts event
end
Proc Callback
You can also pass a Proc via the callback: keyword:
handler = proc { |event| puts event }
Cruise.watch("app/views", callback: handler)
Stopping
The watcher runs in a blocking loop. Use Interrupt to stop it cleanly:
begin
Cruise.watch("app") do |event|
# process event
end
rescue Interrupt
puts "Stopped."
end
Async / Fibers
Cruise.watch cooperates with Ruby's fiber scheduler, the API is identical whether you're on threads or fibers, with no separate "async mode." It waits on an internal pipe via IO#wait_readable, which yields to other fibers when a scheduler is active (for example inside Async) and otherwise blocks the thread with the GVL released.
require "async"
require "cruise"
Async do |task|
task.async do
Cruise.watch("app/views", glob: "**/*.html.erb") do |event|
reload(event.path)
end
end
task.async do
start_dev_server
end
end
Cruise doesn't depend on async, it only relies on the standard IO#wait_readable hook, so any Fiber::Scheduler works.
Lower-level watcher
For custom event loops or manually-driven fibers, Cruise::Watcher exposes a readable IO and a non-blocking #poll:
watcher = Cruise::Watcher.new("app/views", glob: "**/*.erb", debounce: 0.1)
begin
loop do
watcher.io.wait_readable
watcher.io.read_nonblock(4096, exception: false)
while (event = watcher.poll)
handle(event)
end
end
ensure
watcher.close
end
Cruise::Watcher.new takes the same paths keyword arguments as Cruise.watch, it's the watcher Cruise.watch runs internally.
How It Works
Cruise is a Ruby C extension that links a Rust static library (libcruise.a) built around the notify crate. The Rust core exposes a small C ABI (generated with cbindgen) and knows nothing about Ruby. A thin hand-written C wrapper bridges that ABI to Ruby objects via the Ruby C API.
Cruise.watchsets up a notify watcher with event debouncing (100ms)- A background thread monitors filesystem events using the OS-native API
- The main loop calls
rb_thread_call_without_gvlto wait without blocking Ruby - When an event arrives, the GVL is re-acquired and your callback is invoked
Platform Backends
| Platform | Backend | API |
|---|---|---|
| macOS | FSEvents | CoreServices framework |
| Linux | inotify | inotify_init1 syscall |
All backends watch recursively by default.
Prior Art
Cruise builds on ideas from the Ruby ecosystem's existing file watchers:
listenthe long-standing workhorse behind Guard, Rails, and much of the ecosystem. Wraps rb-fsevent (macOS) and rb-inotify (Linux), with a polling fallback for other platforms and network shares.guarda command-line tool, built on listen, that runs tasks (tests, reloads, builds) in response to file changes.io-watcha newer, deliberately minimal library from the socketry project, offering one simple, unified directory-watching interface across platforms without per-platform multiplexing in application code.
Cruise shares io-watch's preference for a single, native-events-only interface over platform multiplexing. On top of that it focuses on what a hot-reload loop needs: built-in debouncing, glob and event-kind filtering, and precompiled native gems so there's nothing to build at install time on supported platforms. It deliberately has no polling adapter, so filesystems where native events aren't delivered fall outside its scope, reach for listen there.
Development
Requirements: Rust toolchain, Ruby 3.2+
git clone https://github.com/marcoroth/cruise
cd cruise
bundle install
bundle exec rake compile
bundle exec rake test
Cross-compilation
Cruise uses rake-compiler and rake-compiler-dock for building native gems:
bundle exec rake gem:native
License
MIT License. See LICENSE.txt.