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 +7 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +269 -0
- data/ext/winwatch/extconf.rb +19 -0
- data/ext/winwatch/winwatch.c +883 -0
- data/lib/winwatch/version.rb +5 -0
- data/lib/winwatch.rb +688 -0
- metadata +125 -0
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")
|