winwatch 0.1.0 → 0.1.1
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 +4 -4
- data/README.md +11 -269
- data/lib/winwatch.rb +4 -688
- metadata +10 -90
- data/CHANGELOG.md +0 -35
- data/ext/winwatch/extconf.rb +0 -19
- data/ext/winwatch/winwatch.c +0 -883
- data/lib/winwatch/version.rb +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d4db1ef94008816165d660b44aa551f6bae9679e63671f65328ac1b52cc1c79f
|
|
4
|
+
data.tar.gz: dad8bb5a396557cc9d068d1dff8c7d472a7892cbb14ef5b1d8fca44c90d462ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e03aabfc07207cdc51f7ee430d9aadbc1d8654e4c22c662227dbbbaae045f17ebe7bb7a60477c77f2757a7529fcba152b080bd6ff0e9bb8348e1b6362a92f2ab
|
|
7
|
+
data.tar.gz: 1998ac9b5fde33bdeeafaa223fa6952970331fb2d5cfecc0423ebe57d91b348006113e9ed379754f4c06a9b448ca5e0fcf387ef67f70fd91a3bb7b439be8aa77
|
data/README.md
CHANGED
|
@@ -1,269 +1,11 @@
|
|
|
1
|
-
# winwatch
|
|
2
|
-
|
|
3
|
-
**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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).
|
|
1
|
+
# winwatch (discontinued)
|
|
2
|
+
|
|
3
|
+
**winwatch is no longer maintained.** This final release (0.1.1) contains no
|
|
4
|
+
code: installing it compiles nothing, and `require "winwatch"` raises a
|
|
5
|
+
`LoadError` explaining the discontinuation.
|
|
6
|
+
|
|
7
|
+
Use listen (with wdm on Windows) instead.
|
|
8
|
+
|
|
9
|
+
Earlier releases remain on RubyGems only because RubyGems does not allow
|
|
10
|
+
versions older than 30 days to be yanked. They are unmaintained and not
|
|
11
|
+
recommended.
|