winwatch 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9d1e96717728a0bf81218f1d8a401bd00352b81aaaa1583e5555c79c01dbca76
4
+ data.tar.gz: 905dacb675b73892dd43712d4dc0c1ee25cc0a9d244f195ab97ce2d9918a998a
5
+ SHA512:
6
+ metadata.gz: a59f6b13bbbae40fd82310c01c7f60496810455ce2b4017e0e41ce464174500a80b2f660517cf910a396d0a342960c4586b14b4cc4be9d34e7857e8ff06761f4
7
+ data.tar.gz: 20cca6e53d6e2aec49524a33984a9c2a69eb7f8b5f77f13da84a8b1e44d1b6bcae626886dc59cc39335d681f1ee96ff202093ff6ab9f26a4d35e6ed2bd5ad8a8
data/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-06-27
4
+
5
+ Initial release.
6
+
7
+ - `Winwatch.watch(path, recursive:, filter:, buffer_size:, normalize_names:)` — the single
8
+ validated entry point, with a block form that ensure-closes the watcher.
9
+ - `Winwatch::Watcher#take(timeout:)` — the blocking pull; returns a non-empty
10
+ `Array<Winwatch::Event>` or `nil` on timeout, releases the GVL, and is interrupt-safe
11
+ (`Thread#kill` / Ctrl-C / `Timeout` break an infinite wait via a per-op `CancelIoEx`).
12
+ - `Winwatch::Watcher#each` — yields events forever; returns after the terminal `:gone` event
13
+ or when the watcher is closed from elsewhere.
14
+ - `Winwatch::Watcher#close` / `#closed?` — idempotent, safe from any thread/fiber.
15
+ - `Winwatch::Watcher#path` / `#recursive?` / `#filter` / `#buffer_size` / `#mode`.
16
+ - `Winwatch::Event(type, path, from, code)` — frozen, pattern-matchable; six types
17
+ `:added` / `:modified` / `:removed` / `:renamed` / `:rescan` / `:gone`. Paths are absolute,
18
+ UTF-8, forward-slash separated.
19
+ - **Lossless overflow signaling**: a kernel buffer overflow surfaces as one `:rescan` event
20
+ (all three RDCW spellings funnel to it), never a silent drop and never an exception.
21
+ - **Terminal `:gone`**: a dying watch (root deleted, volume dismounted, network loss) surfaces
22
+ as one `:gone` event with the Win32 `code`, then auto-closes. `FILE_SHARE_DELETE` means the
23
+ watched root stays deletable.
24
+ - **Fiber-cooperative under winloop**: a `:winloop` mode (gate
25
+ `Fiber.scheduler.respond_to?(:await_op)`, winloop ≥ 0.2) parks fibers on the loop's IOCP
26
+ with zero extra threads; a `:standalone` mode works everywhere else, offloading to a
27
+ `run_blocking` worker under any other active scheduler.
28
+ - `Winwatch.run_blocking` / `Winwatch.ms_for` — the suite's worker-offload and timeout
29
+ helpers.
30
+ - Errors: `Winwatch::Error` → `OSError` (`#code`) → `NotFound` / `AccessDenied` /
31
+ `Unsupported`; `NotADirectory` and `Closed` subclass `Error` directly.
32
+
33
+ Windows MSVC (mswin) Ruby only.
34
+
35
+ [0.1.0]: https://github.com/main-path/winwatch/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ned
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,269 @@
1
+ # winwatch
2
+
3
+ **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.**
4
+
5
+ winwatch is the only Windows directory-watcher gem for native **MSVC (mswin)** Ruby. The
6
+ existing options don't fit: `wdm` is MinGW-oriented and one volunteer deep, and `listen`
7
+ silently degrades to *polling* on Windows unless `wdm` is present. winwatch is standalone,
8
+ thin, and honest about what `ReadDirectoryChangesW` (RDCW) can and cannot do.
9
+
10
+ What RDCW guarantees, winwatch surfaces faithfully; what nothing can guarantee, winwatch
11
+ documents loudly. The headline example: when the kernel drops the change backlog (buffer
12
+ overflow), winwatch emits an explicit **`:rescan`** event so you can re-enumerate the tree
13
+ yourself — it is never a silent drop and never an exception. That is a feature, not a bug:
14
+ overflow is an expected, recoverable condition the consumer owns.
15
+
16
+ Positioning: this is not a polling watcher and not a USN-journal volume monitor — it is the
17
+ direct, per-directory RDCW path, drained with a blocking pull that cooperates with the
18
+ winloop fiber scheduler. A `listen` adapter is a later, separate gem; winwatch v1 only
19
+ guarantees the event shape such an adapter needs (absolute paths, symbolic event types, a
20
+ non-blocking drain).
21
+
22
+ | What | API |
23
+ | --- | --- |
24
+ | Open a watch | `Winwatch.watch(path, recursive:, filter:, buffer_size:, normalize_names:)` |
25
+ | Pull a batch | `watcher.take(timeout: nil) -> Array<Event> \| nil` |
26
+ | Stream events | `watcher.each { \|event\| ... }` |
27
+ | Close | `watcher.close` / `watcher.closed?` |
28
+ | Event | `Winwatch::Event(type, path, from, code)` |
29
+ | Overflow | a `:rescan` event (re-enumerate the tree yourself) |
30
+ | Watch death | a terminal `:gone` event (root deleted, network loss) |
31
+
32
+ ## Requirements
33
+
34
+ - Windows, with a native **MSVC (mswin)** Ruby (`target_os` mswin64, x64). **Not** MinGW/UCRT.
35
+ - Visual Studio 2017 or newer (or Build Tools) with the **"Desktop development with C++"**
36
+ workload — `cl.exe` builds the extension. Building from source needs no Developer Command
37
+ Prompt: the `vcvars` dev-dependency activates the toolchain at `rake compile` time. If a
38
+ build fails, run `vcvars doctor`.
39
+ - arm64-mswin is expected to work (the code is `_WIN64`/`uintptr_t`-clean and the build
40
+ guards on `/mswin/` only) but is untested and unsupported until an arm64-mswin Ruby
41
+ distribution exists.
42
+
43
+ ## Install
44
+
45
+ ```sh
46
+ gem install winwatch
47
+ ```
48
+
49
+ ## Quick start
50
+
51
+ ```ruby
52
+ require "winwatch"
53
+
54
+ # Block until something changes; print one batch.
55
+ Winwatch.watch("C:/projects/app", recursive: true) do |w|
56
+ events = w.take # blocks
57
+ events.each { |e| puts "#{e.type} #{e.path}" }
58
+ # => added C:/projects/app/src/new_file.rb
59
+ end
60
+
61
+ # Long-running consumer with rename + overflow handling.
62
+ Winwatch.watch("C:/data", recursive: true, filter: %i[file_name dir_name last_write]) do |w|
63
+ w.each do |e|
64
+ case e.type
65
+ when :renamed then puts "#{e.from} -> #{e.path}"
66
+ when :rescan then full_resync! # kernel dropped the backlog; diff the tree yourself
67
+ when :gone then warn "watch died (error #{e.code})"; break # each returns anyway
68
+ else puts "#{e.type} #{e.path}"
69
+ end
70
+ end
71
+ end
72
+ ```
73
+
74
+ ## Events
75
+
76
+ `Winwatch.watch` returns a `Winwatch::Watcher`; you pull `Winwatch::Event`s from it. Every
77
+ event is a frozen `Struct(type, path, from, code)` and supports pattern matching:
78
+
79
+ ```ruby
80
+ case event
81
+ in {type: :renamed, from:, path:} then "#{from} -> #{path}"
82
+ in {type: :rescan, path:} then resync(path)
83
+ in {type:, path:} then "#{type} #{path}"
84
+ end
85
+ ```
86
+
87
+ The six event types:
88
+
89
+ - `:added` — a file/dir was created in (or renamed/moved *into*) the tree.
90
+ - `:modified` — contents/metadata changed (per the subscribed `filter:`).
91
+ - `:removed` — a file/dir was deleted from (or renamed/moved *out of*) the tree.
92
+ - `:renamed` — a rename *within* the tree; `from` is the old absolute path, `path` the new.
93
+ - `:rescan` — the kernel dropped the backlog (buffer overflow). **You must re-enumerate the
94
+ tree yourself.** `path` is the watch root. Recoverable; the watch keeps running.
95
+ - `:gone` — terminal: the watch died. `path` is the root, `code` is the Win32 error that
96
+ killed it. The watcher auto-closes after delivering it; the next `take` raises `Closed`.
97
+
98
+ `path` (and `from`) are absolute, UTF-8, **forward-slash** separated, built by joining the
99
+ watch root with the record's relative name. The internal `\\?\` extended-length prefix never
100
+ leaks into events.
101
+
102
+ ### `:rescan` — what it means and what you must do
103
+
104
+ RDCW keeps a fixed-size kernel buffer per watch. Under a storm — a `git checkout`, an
105
+ `npm install`, moving a thousand files — that buffer overflows and **the kernel discards the
106
+ backlog**. winwatch surfaces this as exactly one `:rescan` event (never a silent loss, never
107
+ an exception). When you see `:rescan`, the only correct response is to **re-enumerate /
108
+ diff the watched tree yourself** — the individual change records for that burst are gone.
109
+
110
+ Mitigations, in order (each makes overflow less likely):
111
+
112
+ 1. **Narrow the `filter:`** — subscribe to fewer change classes.
113
+ 2. **Avoid recursive whole-tree watches** where you can.
114
+ 3. **Raise `buffer_size:`** (up to 64 KiB).
115
+ 4. **`take` promptly** — drain fast; do heavy work off the take path.
116
+
117
+ ### `:gone` — when watches die
118
+
119
+ A watch is mortal. The root is deleted, the volume is dismounted, a network share drops —
120
+ RDCW completes the pending operation with a terminal error. winwatch maps **any** terminal
121
+ completion to a single `:gone` event carrying the Win32 `code` (commonly `5` for a
122
+ delete-pending root; `64`/`56`/`1450` for network deaths), then auto-closes. Deleting the
123
+ watched root *works* — winwatch opens the handle with `FILE_SHARE_DELETE`, so it never blocks
124
+ the deletion (the classic "watcher won't let me delete the folder" complaint is designed out)
125
+ — and the watch then reports `:gone`. There is no auto-reconnect; re-create the watch.
126
+
127
+ ## Renames
128
+
129
+ A rename *within* the tree arrives as two records — `RENAMED_OLD_NAME` then
130
+ `RENAMED_NEW_NAME` — usually adjacent. winwatch pairs them into one `:renamed` event
131
+ (`from` = old, `path` = new) using an **adjacent-only** rule, and never withholds an
132
+ already-read event waiting for a partner:
133
+
134
+ - OLD immediately followed by NEW ⇒ one `:renamed`.
135
+ - OLD **not** immediately followed by NEW (including OLD as the last record of a batch) ⇒
136
+ degrades to `:removed` in the same batch. A rare split pair therefore surfaces as
137
+ `:removed` + `:added` across two takes — semantically safe (a rename is a remove + add).
138
+ - NEW with no immediately-preceding OLD ⇒ degrades to `:added`.
139
+ - Rename **into** the tree from outside is `:added`; rename **out of** the tree is `:removed`
140
+ (that is all the kernel reports).
141
+
142
+ Adjacent-only matching deliberately avoids mispairing two interleaved concurrent renames —
143
+ degrading is safer than mispairing. `FileId`-hard pairing (via `ReadDirectoryChangesExW`,
144
+ NTFS-only) is a documented future upgrade, out of scope for v1.
145
+
146
+ ## Cooperative under winloop
147
+
148
+ winwatch is never a winloop dependency — cooperation is duck-typed. The mode is chosen once,
149
+ at `watch` time, and fixed for the watcher's lifetime (associating a handle with a completion
150
+ port is irreversible):
151
+
152
+ ```ruby
153
+ require "winloop" # winloop >= 0.2 (the await_op generic-op protocol)
154
+ require "winwatch"
155
+
156
+ Winloop.run do
157
+ Fiber.schedule do
158
+ Winwatch.watch("C:/projects/app", recursive: true) do |w| # mode => :winloop
159
+ w.each { |e| puts "#{e.type} #{e.path}" } # the fiber parks here
160
+ end
161
+ end
162
+ Fiber.schedule { other_io_work } # the loop keeps serving while the watcher waits
163
+ end
164
+ ```
165
+
166
+ | Environment at `watch` time | Mode | `take` blocking point |
167
+ | --- | --- | --- |
168
+ | no scheduler | `:standalone` | inline native wait, GVL released, cancelable |
169
+ | scheduler without `await_op` (async, winloop 0.1) | `:standalone` | offloaded to a `run_blocking` worker; the fiber parks on `Thread#value` |
170
+ | scheduler with `await_op` (winloop ≥ 0.2) | `:winloop` | the fiber parks on the loop's completion port; **zero extra threads** |
171
+
172
+ **An open watcher pins the loop**: in `:winloop` mode its awaited operation keeps
173
+ `Winloop.run` from returning (by design, like an open socket). Use the block form of
174
+ `Winwatch.watch` — it ensure-closes — so the loop can exit.
175
+
176
+ A watcher created standalone keeps working if a scheduler appears later (the offload is
177
+ re-checked per call). The standalone-under-a-foreign-scheduler path inherits winipc's
178
+ documented `run_blocking` lost-acquisition window: a fiber unwound (e.g. by `Timeout.timeout`)
179
+ after the worker pulled a batch but before `Thread#value` delivered it loses that batch with
180
+ the killed worker. The in-C interrupt stash makes inline standalone takes and `:winloop` mode
181
+ lossless across the interrupt window; the worker-offload path is never claimed lossless.
182
+
183
+ ## Caveats Windows makes everyone live with
184
+
185
+ These are limits of `ReadDirectoryChangesW` itself, not of winwatch:
186
+
187
+ - **The watched root's own changes are never reported.** Renaming or re-attributing the root
188
+ produces no record. Watch the *parent* if you need that.
189
+ - **Root rename silently de-syncs paths.** The handle tracks the object; after an external
190
+ rename of the root, events keep arriving but their joined absolute paths no longer exist on
191
+ disk. winwatch does not detect this (no `GetFinalPathNameByHandle` polling in v1).
192
+ - **Ancestor renames fail while watching.** A recursive watch holds an open handle beneath
193
+ every ancestor; NTFS fails renaming any of them (commonly `ERROR_ACCESS_DENIED`). Your
194
+ editor can't rename the parent folder while it is watched.
195
+ - **Duplicates and editor save-via-rename.** You will see multiple `:modified` per logical
196
+ save, antivirus/indexer echo events, and write-temp-then-rename chains. winwatch delivers
197
+ events **raw** — no dedup, no debounce (that is consumer policy).
198
+ - **Memory-mapped writes can be invisible** until the cache flushes; `last_write`/`size`
199
+ notifications can lag the actual write.
200
+ - **8.3 short names.** A record may carry a `LONGFI~1.TXT`-style alias. With
201
+ `normalize_names: true` (default) winwatch best-effort resolves tilde components via
202
+ `GetLongPathNameW`; a deleted file (or a volume without short names) can't be normalized, so
203
+ the raw name is delivered — never an error. Set `normalize_names: false` to skip it entirely.
204
+ - **File vs directory is not knowable** from the classic record, and winwatch will not probe
205
+ the disk to find out (racy, and it would fabricate I/O storms). Subscribe `:dir_name`
206
+ and/or `stat` the path yourself.
207
+ - **SMB / file-system tiers.** Local NTFS is first-class. SMB works with caveats
208
+ (server-dependent; the watch can die on connection loss → `:gone`). ReFS works minus 8.3 and
209
+ minus the Ex extensions. CD/DVD never notifies.
210
+ - **Don't watch whole drives.** Temp/cache churn overflows the buffer and a rescan of a huge
211
+ tree is expensive *for your app*. For volume-scale monitoring, use the
212
+ [USN change journal](https://learn.microsoft.com/windows/win32/fileio/change-journals)
213
+ instead.
214
+
215
+ ## Library API
216
+
217
+ ```ruby
218
+ Winwatch.watch(path, recursive: false, filter: Winwatch::DEFAULT_FILTER,
219
+ buffer_size: 65_536, normalize_names: true) -> Winwatch::Watcher
220
+ Winwatch.watch(path, **opts) { |watcher| ... } -> block value # watcher ensure-closed
221
+
222
+ watcher.take(timeout: nil) # => Array<Winwatch::Event> | nil (nil on timeout; raises Closed)
223
+ watcher.each { |event| ... } # => nil (yields forever; returns after :gone or external close)
224
+ watcher.close # => nil (idempotent; safe from any thread/fiber)
225
+ watcher.closed? # => true | false
226
+ watcher.path # => String (absolute UTF-8 root, as resolved at watch time)
227
+ watcher.recursive? # => true | false
228
+ watcher.filter # => Array<Symbol> (the subscribed filter keys)
229
+ watcher.buffer_size # => Integer (the effective, DWORD-rounded size)
230
+ watcher.mode # => :winloop | :standalone
231
+
232
+ Winwatch::DEFAULT_FILTER # => %i[file_name dir_name last_write size]
233
+ Winwatch::FILTER_FLAGS # => { file_name:, dir_name:, attributes:, size:, last_write:,
234
+ # last_access:, creation:, security: }
235
+ Winwatch::MIN_BUFFER_SIZE # => 4_096
236
+ Winwatch::MAX_BUFFER_SIZE # => 65_536
237
+ ```
238
+
239
+ ## How it works
240
+
241
+ One overlapped `ReadDirectoryChangesW` is outstanding per watch at all times. The first is
242
+ issued inside `watch`, so changes between `watch` and the first `take` are captured (the
243
+ kernel accumulates them in a per-handle mirror buffer between calls, which is why the pull
244
+ design is lossless across `take` gaps short of overflow). On each completion winwatch copies
245
+ the batch out and **re-arms before parsing**, minimizing the no-op-outstanding window. The
246
+ single blocking wait releases the GVL with a real per-op `CancelIoEx` unblock function, so
247
+ `Thread#kill` / Ctrl-C / `Timeout` break an infinite wait. Under winloop ≥ 0.2 a pump fiber
248
+ parks on the loop's IOCP via `await_op` and pushes parsed events onto an internal queue your
249
+ `take`/`each` drains — zero extra threads.
250
+
251
+ ## Errors
252
+
253
+ ```
254
+ Winwatch::Error < StandardError # root; API misuse uses the subclasses below
255
+ Winwatch::OSError < Error # carries @code (Win32 error), reader #code
256
+ Winwatch::NotFound < OSError # no such path at open (error 2 / 3)
257
+ Winwatch::AccessDenied < OSError # access denied at open (error 5)
258
+ Winwatch::Unsupported < OSError # FS / redirector can't change-notify (error 1)
259
+ Winwatch::NotADirectory < Error # path exists but is not a directory (misuse)
260
+ Winwatch::Closed < Error # take/each on a closed (or :gone) watcher
261
+ ```
262
+
263
+ Open-time failures and API misuse raise; **mid-life death of a healthy watch is an event
264
+ (`:gone`), never an exception**. Plain argument-shape errors raise Ruby's own
265
+ `ArgumentError` / `TypeError`.
266
+
267
+ ## License
268
+
269
+ [MIT](LICENSE.txt).
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+ #
3
+ # extconf.rb for the winwatch extension — ReadDirectoryChangesW directory
4
+ # watching with overlapped (async) directory I/O.
5
+
6
+ require "mkmf"
7
+
8
+ unless RbConfig::CONFIG["target_os"] =~ /mswin/
9
+ abort <<~MSG
10
+ winwatch requires a native Windows MSVC (mswin) Ruby — it binds
11
+ ReadDirectoryChangesW and overlapped directory I/O, and is built with
12
+ cl.exe. Your Ruby is "#{RbConfig::CONFIG['arch']}".
13
+ MSG
14
+ end
15
+
16
+ # Pure C — no -EHsc, so rb_raise/longjmp is the normal, safe error mechanism.
17
+ # kernel32 only (linked by default): CreateFileW / ReadDirectoryChangesW /
18
+ # CancelIoEx / event waits / GetOverlappedResult / GetLongPathNameW.
19
+ create_makefile("winwatch/winwatch")