Watching files for changes

Polling the timestamp

The simplest watcher is a comparison. Remember File.mtime(path), look again later, and the file changed when the two are different. The timestamp has sub-second precision on a modern file system, so two reads a tenth of a second apart on an untouched file agree exactly:

ruby -e 'File.write("a.txt", "x\n"); 2.times { puts File.mtime("a.txt").strftime("%H:%M:%S.%N"); sleep 0.1 }'

00:20:26.417165513
00:20:26.417165513

Identical lines mean no change; a different line means the contents or the metadata moved. %N prints nanoseconds, which is what makes the comparison meaningful for a quick edit.

A related question is cheaper still: was the file touched recently? mtime > Time.now - 60 answers "less than a minute ago" without any stored state:

ruby -e 'File.write("a.txt", "x\n"); p File.mtime("a.txt") > Time.now - 60'

true

A loop with a stop condition

A watcher is a loop that must end. Here it ends when the timestamp moves or when a deadline passes, and the writer is a thread that plays the part of the editor:

path = "watched.txt" File.write(path, "one\n") seen = File.mtime(path) # stand-in for the process that writes the file writer = Thread.new do sleep 0.5 File.write(path, "two\n") end deadline = Time.now + 3 loop do now = File.mtime(path) if now != seen puts "changed at #{now.strftime("%H:%M:%S.%N")}" break end break puts "timeout" if Time.now > deadline sleep 0.2 end writer.join

ruby watch.rb

changed at 00:14:57.602786520

The deadline is the important line. A watcher without one runs until somebody kills it, and a test that waits for a change it never gets hangs the suite. The interval of 0.2 seconds is the other knob: a fast poll notices a change quickly and costs a stat every fifth of a second.

What polling misses

Polling compares states at intervals, so it cannot see a change that starts and ends between two looks. Three edits in one interval look like one, and an edit that restores the old mtime is invisible. On a file system with one-second timestamps, a quick rewrite in the same second may not move the value at all.

The other cost is the wait itself: with a 0.2 second interval a change is noticed up to 0.2 seconds late, and a watcher for many files does one stat per file per interval. That is fine for a configuration file and for a script that regenerates output, and it is the wrong shape for a development server that watches a whole source tree.

When a watcher gem is worth it

A real watcher asks the operating system instead of the clock: inotify on Linux, FSEvents on macOS, ReadDirectoryChangesW on Windows. The listen gem wraps all three, and a reloader such as Guard or the Rails development server is built on it:

require "listen" listener = Listen.to("src") do |modified, added, removed| puts "modified: #{modified.size}" puts "added: #{added.size}" puts "removed: #{removed.size}" end listener.start sleep

The events arrive as lists of paths, and the callback runs once per batch. The trade is a gem and a native dependency against missing an edit, and for a folder a person edits in an editor the gem wins.

listen is a gem, so the block above is a sketch rather than a command run here. Add it to the Gemfile and run the script: the library picks the backend per platform.

What to watch for

File.mtime raises Errno::ENOENT while the file does not exist. A watcher that also cares about creation checks File.exist? first and treats a missing file as a state, not an error.

An editor often saves by writing a new file and renaming it over the old one, so the path keeps existing but the inode changes. Polling sees the new mtime and is fine; an open handle from before the save still points at the old contents, which is why a reloader reopens the file.

This is the last lesson of the chapter. The Ruby course index has the whole tree, and the next lesson is String tools.

Article author: Arthur Isaev