Cruise

A fast, OS-native file watcher for Ruby.

Gem Version License

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.

  1. Cruise.watch sets up a notify watcher with event debouncing (100ms)
  2. A background thread monitors filesystem events using the OS-native API
  3. The main loop calls rb_thread_call_without_gvl to wait without blocking Ruby
  4. 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:

  • listen the 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.
  • guard a command-line tool, built on listen, that runs tasks (tests, reloads, builds) in response to file changes.
  • io-watch a 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.