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_osmswin64, x64). Not MinGW/UCRT. - Visual Studio 2017 or newer (or Build Tools) with the "Desktop development with C++"
workload —
cl.exebuilds the extension. Building from source needs no Developer Command Prompt: thevcvarsdev-dependency activates the toolchain atrake compiletime. If a build fails, runvcvars 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 subscribedfilter:).:removed— a file/dir was deleted from (or renamed/moved out of) the tree.:renamed— a rename within the tree;fromis the old absolute path,paththe new.:rescan— the kernel dropped the backlog (buffer overflow). You must re-enumerate the tree yourself.pathis the watch root. Recoverable; the watch keeps running.:gone— terminal: the watch died.pathis the root,codeis the Win32 error that killed it. The watcher auto-closes after delivering it; the nexttakeraisesClosed.
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):
- Narrow the
filter:— subscribe to fewer change classes. - Avoid recursive whole-tree watches where you can.
- Raise
buffer_size:(up to 64 KiB). takepromptly — 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
:removedin the same batch. A rare split pair therefore surfaces as:removed+:addedacross 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
GetFinalPathNameByHandlepolling 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
:modifiedper 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/sizenotifications can lag the actual write. - 8.3 short names. A record may carry a
LONGFI~1.TXT-style alias. Withnormalize_names: true(default) winwatch best-effort resolves tilde components viaGetLongPathNameW; a deleted file (or a volume without short names) can't be normalized, so the raw name is delivered — never an error. Setnormalize_names: falseto 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_nameand/orstatthe 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.