winwatch

Watch a directory tree the way Windows means it: one ReadDirectoryChangesW watcher with lossless overflow signaling (:rescan, never silent drops) — parking fibers on the winloop IOCP when a scheduler is active, a plain blocking pull everywhere else.

winwatch is the only Windows directory-watcher gem for native MSVC (mswin) Ruby. The existing options don't fit: wdm is MinGW-oriented and one volunteer deep, and listen silently degrades to polling on Windows unless wdm is present. winwatch is standalone, thin, and honest about what ReadDirectoryChangesW (RDCW) can and cannot do.

What RDCW guarantees, winwatch surfaces faithfully; what nothing can guarantee, winwatch documents loudly. The headline example: when the kernel drops the change backlog (buffer overflow), winwatch emits an explicit :rescan event so you can re-enumerate the tree yourself — it is never a silent drop and never an exception. That is a feature, not a bug: overflow is an expected, recoverable condition the consumer owns.

Positioning: this is not a polling watcher and not a USN-journal volume monitor — it is the direct, per-directory RDCW path, drained with a blocking pull that cooperates with the winloop fiber scheduler. A listen adapter is a later, separate gem; winwatch v1 only guarantees the event shape such an adapter needs (absolute paths, symbolic event types, a non-blocking drain).

What API
Open a watch Winwatch.watch(path, recursive:, filter:, buffer_size:, normalize_names:)
Pull a batch `watcher.take(timeout: nil) -> Array \ nil`
Stream events `watcher.each { \ event\ ... }`
Close watcher.close / watcher.closed?
Event Winwatch::Event(type, path, from, code)
Overflow a :rescan event (re-enumerate the tree yourself)
Watch death a terminal :gone event (root deleted, network loss)

Requirements

  • Windows, with a native MSVC (mswin) Ruby (target_os mswin64, x64). Not MinGW/UCRT.
  • Visual Studio 2017 or newer (or Build Tools) with the "Desktop development with C++" workload — cl.exe builds the extension. Building from source needs no Developer Command Prompt: the vcvars dev-dependency activates the toolchain at rake compile time. If a build fails, run vcvars doctor.
  • arm64-mswin is expected to work (the code is _WIN64/uintptr_t-clean and the build guards on /mswin/ only) but is untested and unsupported until an arm64-mswin Ruby distribution exists.

Install

gem install winwatch

Quick start

require "winwatch"

# Block until something changes; print one batch.
Winwatch.watch("C:/projects/app", recursive: true) do |w|
  events = w.take                       # blocks
  events.each { |e| puts "#{e.type} #{e.path}" }
  # => added C:/projects/app/src/new_file.rb
end

# Long-running consumer with rename + overflow handling.
Winwatch.watch("C:/data", recursive: true, filter: %i[file_name dir_name last_write]) do |w|
  w.each do |e|
    case e.type
    when :renamed then puts "#{e.from} -> #{e.path}"
    when :rescan  then full_resync!     # kernel dropped the backlog; diff the tree yourself
    when :gone    then warn "watch died (error #{e.code})"; break  # each returns anyway
    else               puts "#{e.type} #{e.path}"
    end
  end
end

Events

Winwatch.watch returns a Winwatch::Watcher; you pull Winwatch::Events from it. Every event is a frozen Struct(type, path, from, code) and supports pattern matching:

case event
in {type: :renamed, from:, path:} then "#{from} -> #{path}"
in {type: :rescan,  path:}        then resync(path)
in {type:, path:}                 then "#{type} #{path}"
end

The six event types:

  • :added — a file/dir was created in (or renamed/moved into) the tree.
  • :modified — contents/metadata changed (per the subscribed filter:).
  • :removed — a file/dir was deleted from (or renamed/moved out of) the tree.
  • :renamed — a rename within the tree; from is the old absolute path, path the new.
  • :rescan — the kernel dropped the backlog (buffer overflow). You must re-enumerate the tree yourself. path is the watch root. Recoverable; the watch keeps running.
  • :gone — terminal: the watch died. path is the root, code is the Win32 error that killed it. The watcher auto-closes after delivering it; the next take raises Closed.

path (and from) are absolute, UTF-8, forward-slash separated, built by joining the watch root with the record's relative name. The internal \\?\ extended-length prefix never leaks into events.

:rescan — what it means and what you must do

RDCW keeps a fixed-size kernel buffer per watch. Under a storm — a git checkout, an npm install, moving a thousand files — that buffer overflows and the kernel discards the backlog. winwatch surfaces this as exactly one :rescan event (never a silent loss, never an exception). When you see :rescan, the only correct response is to re-enumerate / diff the watched tree yourself — the individual change records for that burst are gone.

Mitigations, in order (each makes overflow less likely):

  1. Narrow the filter: — subscribe to fewer change classes.
  2. Avoid recursive whole-tree watches where you can.
  3. Raise buffer_size: (up to 64 KiB).
  4. take promptly — drain fast; do heavy work off the take path.

:gone — when watches die

A watch is mortal. The root is deleted, the volume is dismounted, a network share drops — RDCW completes the pending operation with a terminal error. winwatch maps any terminal completion to a single :gone event carrying the Win32 code (commonly 5 for a delete-pending root; 64/56/1450 for network deaths), then auto-closes. Deleting the watched root works — winwatch opens the handle with FILE_SHARE_DELETE, so it never blocks the deletion (the classic "watcher won't let me delete the folder" complaint is designed out) — and the watch then reports :gone. There is no auto-reconnect; re-create the watch.

Renames

A rename within the tree arrives as two records — RENAMED_OLD_NAME then RENAMED_NEW_NAME — usually adjacent. winwatch pairs them into one :renamed event (from = old, path = new) using an adjacent-only rule, and never withholds an already-read event waiting for a partner:

  • OLD immediately followed by NEW ⇒ one :renamed.
  • OLD not immediately followed by NEW (including OLD as the last record of a batch) ⇒ degrades to :removed in the same batch. A rare split pair therefore surfaces as :removed + :added across two takes — semantically safe (a rename is a remove + add).
  • NEW with no immediately-preceding OLD ⇒ degrades to :added.
  • Rename into the tree from outside is :added; rename out of the tree is :removed (that is all the kernel reports).

Adjacent-only matching deliberately avoids mispairing two interleaved concurrent renames — degrading is safer than mispairing. FileId-hard pairing (via ReadDirectoryChangesExW, NTFS-only) is a documented future upgrade, out of scope for v1.

Cooperative under winloop

winwatch is never a winloop dependency — cooperation is duck-typed. The mode is chosen once, at watch time, and fixed for the watcher's lifetime (associating a handle with a completion port is irreversible):

require "winloop"   # winloop >= 0.2 (the await_op generic-op protocol)
require "winwatch"

Winloop.run do
  Fiber.schedule do
    Winwatch.watch("C:/projects/app", recursive: true) do |w|  # mode => :winloop
      w.each { |e| puts "#{e.type} #{e.path}" }                # the fiber parks here
    end
  end
  Fiber.schedule { other_io_work }   # the loop keeps serving while the watcher waits
end
Environment at watch time Mode take blocking point
no scheduler :standalone inline native wait, GVL released, cancelable
scheduler without await_op (async, winloop 0.1) :standalone offloaded to a run_blocking worker; the fiber parks on Thread#value
scheduler with await_op (winloop ≥ 0.2) :winloop the fiber parks on the loop's completion port; zero extra threads

An open watcher pins the loop: in :winloop mode its awaited operation keeps Winloop.run from returning (by design, like an open socket). Use the block form of Winwatch.watch — it ensure-closes — so the loop can exit.

A watcher created standalone keeps working if a scheduler appears later (the offload is re-checked per call). The standalone-under-a-foreign-scheduler path inherits winipc's documented run_blocking lost-acquisition window: a fiber unwound (e.g. by Timeout.timeout) after the worker pulled a batch but before Thread#value delivered it loses that batch with the killed worker. The in-C interrupt stash makes inline standalone takes and :winloop mode lossless across the interrupt window; the worker-offload path is never claimed lossless.

Caveats Windows makes everyone live with

These are limits of ReadDirectoryChangesW itself, not of winwatch:

  • The watched root's own changes are never reported. Renaming or re-attributing the root produces no record. Watch the parent if you need that.
  • Root rename silently de-syncs paths. The handle tracks the object; after an external rename of the root, events keep arriving but their joined absolute paths no longer exist on disk. winwatch does not detect this (no GetFinalPathNameByHandle polling in v1).
  • Ancestor renames fail while watching. A recursive watch holds an open handle beneath every ancestor; NTFS fails renaming any of them (commonly ERROR_ACCESS_DENIED). Your editor can't rename the parent folder while it is watched.
  • Duplicates and editor save-via-rename. You will see multiple :modified per logical save, antivirus/indexer echo events, and write-temp-then-rename chains. winwatch delivers events raw — no dedup, no debounce (that is consumer policy).
  • Memory-mapped writes can be invisible until the cache flushes; last_write/size notifications can lag the actual write.
  • 8.3 short names. A record may carry a LONGFI~1.TXT-style alias. With normalize_names: true (default) winwatch best-effort resolves tilde components via GetLongPathNameW; a deleted file (or a volume without short names) can't be normalized, so the raw name is delivered — never an error. Set normalize_names: false to skip it entirely.
  • File vs directory is not knowable from the classic record, and winwatch will not probe the disk to find out (racy, and it would fabricate I/O storms). Subscribe :dir_name and/or stat the path yourself.
  • SMB / file-system tiers. Local NTFS is first-class. SMB works with caveats (server-dependent; the watch can die on connection loss → :gone). ReFS works minus 8.3 and minus the Ex extensions. CD/DVD never notifies.
  • Don't watch whole drives. Temp/cache churn overflows the buffer and a rescan of a huge tree is expensive for your app. For volume-scale monitoring, use the USN change journal instead.

Library API

Winwatch.watch(path, recursive: false, filter: Winwatch::DEFAULT_FILTER,
               buffer_size: 65_536, normalize_names: true) -> Winwatch::Watcher
Winwatch.watch(path, **opts) { |watcher| ... } -> block value   # watcher ensure-closed

watcher.take(timeout: nil)  # => Array<Winwatch::Event> | nil (nil on timeout; raises Closed)
watcher.each { |event| ... } # => nil  (yields forever; returns after :gone or external close)
watcher.close               # => nil  (idempotent; safe from any thread/fiber)
watcher.closed?             # => true | false
watcher.path                # => String   (absolute UTF-8 root, as resolved at watch time)
watcher.recursive?          # => true | false
watcher.filter              # => Array<Symbol>  (the subscribed filter keys)
watcher.buffer_size         # => Integer        (the effective, DWORD-rounded size)
watcher.mode                # => :winloop | :standalone

Winwatch::DEFAULT_FILTER    # => %i[file_name dir_name last_write size]
Winwatch::FILTER_FLAGS      # => { file_name:, dir_name:, attributes:, size:, last_write:,
                            #      last_access:, creation:, security: }
Winwatch::MIN_BUFFER_SIZE   # => 4_096
Winwatch::MAX_BUFFER_SIZE   # => 65_536

How it works

One overlapped ReadDirectoryChangesW is outstanding per watch at all times. The first is issued inside watch, so changes between watch and the first take are captured (the kernel accumulates them in a per-handle mirror buffer between calls, which is why the pull design is lossless across take gaps short of overflow). On each completion winwatch copies the batch out and re-arms before parsing, minimizing the no-op-outstanding window. The single blocking wait releases the GVL with a real per-op CancelIoEx unblock function, so Thread#kill / Ctrl-C / Timeout break an infinite wait. Under winloop ≥ 0.2 a pump fiber parks on the loop's IOCP via await_op and pushes parsed events onto an internal queue your take/each drains — zero extra threads.

Errors

Winwatch::Error            < StandardError   # root; API misuse uses the subclasses below
  Winwatch::OSError        < Error           # carries @code (Win32 error), reader #code
    Winwatch::NotFound     < OSError         # no such path at open (error 2 / 3)
    Winwatch::AccessDenied < OSError         # access denied at open (error 5)
    Winwatch::Unsupported  < OSError         # FS / redirector can't change-notify (error 1)
  Winwatch::NotADirectory  < Error           # path exists but is not a directory (misuse)
  Winwatch::Closed         < Error           # take/each on a closed (or :gone) watcher

Open-time failures and API misuse raise; mid-life death of a healthy watch is an event (:gone), never an exception. Plain argument-shape errors raise Ruby's own ArgumentError / TypeError.

License

MIT.