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
data/lib/winwatch.rb
ADDED
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "winwatch/version"
|
|
4
|
+
require "winwatch/winwatch" # native extension: Watcher + errors + _parse/_long_path
|
|
5
|
+
|
|
6
|
+
# winwatch — watch a directory tree the way Windows means it: one
|
|
7
|
+
# ReadDirectoryChangesW watcher with lossless overflow signaling (:rescan, never
|
|
8
|
+
# silent drops). Parks fibers on the winloop IOCP when a scheduler is active, a
|
|
9
|
+
# plain blocking pull everywhere else.
|
|
10
|
+
#
|
|
11
|
+
# require "winwatch"
|
|
12
|
+
#
|
|
13
|
+
# Winwatch.watch("C:/projects/app", recursive: true) do |w|
|
|
14
|
+
# w.each do |e|
|
|
15
|
+
# case e.type
|
|
16
|
+
# when :renamed then puts "#{e.from} -> #{e.path}"
|
|
17
|
+
# when :rescan then full_resync! # kernel dropped the backlog
|
|
18
|
+
# when :gone then warn "watch died (#{e.code})"; break
|
|
19
|
+
# else puts "#{e.type} #{e.path}"
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
module Winwatch
|
|
24
|
+
# ---- Win32 notify-filter flag values (verified, rdcw §3) ----
|
|
25
|
+
FILTER_FLAGS = {
|
|
26
|
+
file_name: 0x001, # FILE_NOTIFY_CHANGE_FILE_NAME -> :added/:removed/:renamed for files
|
|
27
|
+
dir_name: 0x002, # FILE_NOTIFY_CHANGE_DIR_NAME -> :added/:removed/:renamed for directories
|
|
28
|
+
attributes: 0x004, # FILE_NOTIFY_CHANGE_ATTRIBUTES -> :modified
|
|
29
|
+
size: 0x008, # FILE_NOTIFY_CHANGE_SIZE -> :modified
|
|
30
|
+
last_write: 0x010, # FILE_NOTIFY_CHANGE_LAST_WRITE -> :modified
|
|
31
|
+
last_access: 0x020, # FILE_NOTIFY_CHANGE_LAST_ACCESS -> :modified (noisy; opt-in)
|
|
32
|
+
creation: 0x040, # FILE_NOTIFY_CHANGE_CREATION -> :modified
|
|
33
|
+
security: 0x100 # FILE_NOTIFY_CHANGE_SECURITY -> :modified
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
DEFAULT_FILTER = %i[file_name dir_name last_write size].freeze # rdcw §3 recommendation
|
|
37
|
+
MIN_BUFFER_SIZE = 4_096 # .NET FSW floor (rdcw §4)
|
|
38
|
+
MAX_BUFFER_SIZE = 65_536 # SMB protocol cap; non-paged-pool sanity (rdcw §4)
|
|
39
|
+
|
|
40
|
+
# ---- FILE_ACTION_* values (verified, rdcw §3) ----
|
|
41
|
+
ACTION_ADDED = 1
|
|
42
|
+
ACTION_REMOVED = 2
|
|
43
|
+
ACTION_MODIFIED = 3
|
|
44
|
+
ACTION_RENAMED_OLD_NAME = 4
|
|
45
|
+
ACTION_RENAMED_NEW_NAME = 5
|
|
46
|
+
ACTION_RESCAN = -1 # synthetic, from a malformed record chain (_parse)
|
|
47
|
+
|
|
48
|
+
# ---- completion-classification Win32 codes (§5.0) ----
|
|
49
|
+
ERROR_NOTIFY_ENUM_DIR = 1022 # overflow, failure spelling -> :rescan
|
|
50
|
+
ERROR_OPERATION_ABORTED = 995 # a cancel (ours or thread rundown)
|
|
51
|
+
ERROR_NOTIFY_CLEANUP = 745 # our own handle close completed the op
|
|
52
|
+
ERROR_ACCESS_DENIED = 5 # root delete-pending -> :gone
|
|
53
|
+
|
|
54
|
+
# A frozen event. Struct supplies deconstruct/deconstruct_keys for pattern
|
|
55
|
+
# matching: `event in {type: :renamed, from:, path:}`.
|
|
56
|
+
#
|
|
57
|
+
# type - Symbol: :added :modified :removed :renamed :rescan :gone
|
|
58
|
+
# path - String, absolute, UTF-8, forward-slash separated (watch root for
|
|
59
|
+
# :rescan/:gone)
|
|
60
|
+
# from - String (old absolute path) for :renamed, else nil
|
|
61
|
+
# code - Integer Win32 error for :gone, else nil
|
|
62
|
+
Event = Struct.new(:type, :path, :from, :code)
|
|
63
|
+
|
|
64
|
+
# A Windows API failure carries the originating error code (set in C).
|
|
65
|
+
class OSError
|
|
66
|
+
def code
|
|
67
|
+
@code
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
module_function
|
|
72
|
+
|
|
73
|
+
# The only constructor. Resolves +path+ with File.absolute_path, opens the
|
|
74
|
+
# directory and issues the first ReadDirectoryChangesW before returning (so
|
|
75
|
+
# changes between watch and the first take are captured). Selects the mode
|
|
76
|
+
# once: :winloop when Fiber.scheduler&.respond_to?(:await_op) at call time,
|
|
77
|
+
# else :standalone. Yields the watcher (ensure-closed) when a block is given.
|
|
78
|
+
#
|
|
79
|
+
# Raises: Winwatch::NotFound / NotADirectory / AccessDenied / Unsupported /
|
|
80
|
+
# OSError (open/first-issue), ArgumentError / TypeError (bad options).
|
|
81
|
+
def watch(path, recursive: false, filter: DEFAULT_FILTER,
|
|
82
|
+
buffer_size: MAX_BUFFER_SIZE, normalize_names: true)
|
|
83
|
+
abs = File.absolute_path(path.to_s)
|
|
84
|
+
keys = filter_keys(filter)
|
|
85
|
+
flags = keys.inject(0) { |acc, k| acc | FILTER_FLAGS[k] }
|
|
86
|
+
bytes = normalize_buffer_size(buffer_size)
|
|
87
|
+
sched = Fiber.scheduler
|
|
88
|
+
winloop = sched.respond_to?(:await_op)
|
|
89
|
+
|
|
90
|
+
watcher = Watcher.send(:build, abs, keys.freeze, flags, recursive ? true : false, bytes,
|
|
91
|
+
normalize_names ? true : false, winloop, sched)
|
|
92
|
+
return watcher unless block_given?
|
|
93
|
+
|
|
94
|
+
begin
|
|
95
|
+
yield watcher
|
|
96
|
+
ensure
|
|
97
|
+
watcher.close
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Run a blocking native call cooperatively. Under a Fiber scheduler (without
|
|
102
|
+
# await_op — async, winloop 0.1) the call is offloaded to a worker Thread so
|
|
103
|
+
# the calling fiber parks (Thread#value routes through the scheduler) and the
|
|
104
|
+
# loop keeps serving; with no scheduler it runs inline (the C call already
|
|
105
|
+
# releases the GVL). On fiber unwind the worker is killed+joined so it can't
|
|
106
|
+
# leak or consume data destined for a later op.
|
|
107
|
+
#
|
|
108
|
+
# Caveat: if the fiber is unwound (e.g. Timeout) in the instant after the
|
|
109
|
+
# worker's _take returned a batch but before Thread#value delivered it, that
|
|
110
|
+
# batch is lost with the killed worker — the documented winipc lost-
|
|
111
|
+
# acquisition window (the in-C stash, §5.12, does not cover this path).
|
|
112
|
+
def run_blocking
|
|
113
|
+
sched = Fiber.scheduler
|
|
114
|
+
return yield unless sched
|
|
115
|
+
|
|
116
|
+
worker = Thread.new do
|
|
117
|
+
Thread.current.report_on_exception = false
|
|
118
|
+
yield
|
|
119
|
+
end
|
|
120
|
+
begin
|
|
121
|
+
worker.value
|
|
122
|
+
ensure
|
|
123
|
+
if worker.alive?
|
|
124
|
+
worker.kill
|
|
125
|
+
worker.join
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# nil -> -1 (INFINITE); non-negative seconds -> ms, tiny positive rounds up to
|
|
131
|
+
# 1 ms (never collapses to a poll); negative -> ArgumentError.
|
|
132
|
+
def ms_for(timeout)
|
|
133
|
+
return -1 if timeout.nil?
|
|
134
|
+
|
|
135
|
+
t = Float(timeout)
|
|
136
|
+
raise ArgumentError, "timeout must be non-negative, got #{timeout.inspect}" if t.negative?
|
|
137
|
+
|
|
138
|
+
ms = (t * 1000).round
|
|
139
|
+
ms.zero? && t.positive? ? 1 : ms
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Validate the filter (non-empty Array of FILTER_FLAGS keys; a lone Symbol is
|
|
143
|
+
# wrapped) and return the de-duplicated key Array in the requested order.
|
|
144
|
+
def filter_keys(filter)
|
|
145
|
+
keys = filter.is_a?(Symbol) ? [filter] : filter
|
|
146
|
+
unless keys.is_a?(Array) && !keys.empty?
|
|
147
|
+
raise ArgumentError, "filter must be a non-empty Array of #{FILTER_FLAGS.keys.inspect} (got #{filter.inspect})"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
keys.each do |key|
|
|
151
|
+
unless FILTER_FLAGS.key?(key)
|
|
152
|
+
raise ArgumentError,
|
|
153
|
+
"unknown filter flag #{key.inspect}; expected one of #{FILTER_FLAGS.keys.inspect}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
keys.uniq
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Validate buffer_size (Integer 4_096..65_536) and round up to a multiple of 4
|
|
160
|
+
# (DWORD alignment). Out of range -> ArgumentError.
|
|
161
|
+
def normalize_buffer_size(size)
|
|
162
|
+
n = Integer(size)
|
|
163
|
+
unless n.between?(MIN_BUFFER_SIZE, MAX_BUFFER_SIZE)
|
|
164
|
+
raise ArgumentError,
|
|
165
|
+
"buffer_size must be #{MIN_BUFFER_SIZE}..#{MAX_BUFFER_SIZE}, got #{size.inspect}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
(n + 3) & ~3
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private_class_method :filter_keys, :normalize_buffer_size
|
|
172
|
+
|
|
173
|
+
# The directory watcher. Constructed only through Winwatch.watch (.new is
|
|
174
|
+
# private). The native superclass supplies the underscore-private primitives
|
|
175
|
+
# (_open / _take / _issue / _cancel / _close_* / closed? / _parse bridge).
|
|
176
|
+
class Watcher
|
|
177
|
+
private_class_method :new
|
|
178
|
+
|
|
179
|
+
# ---- construction (internal; called by Winwatch.watch) -----------------
|
|
180
|
+
|
|
181
|
+
def self.build(abs, keys, flags, recursive, bytes, normalize, winloop, sched)
|
|
182
|
+
watcher = _open(abs, flags, recursive, bytes, winloop)
|
|
183
|
+
watcher.send(:init_state, abs, keys, recursive, bytes, normalize, winloop, sched)
|
|
184
|
+
watcher
|
|
185
|
+
end
|
|
186
|
+
private_class_method :build
|
|
187
|
+
|
|
188
|
+
def init_state(abs, keys, recursive, bytes, normalize, winloop, sched)
|
|
189
|
+
@path = abs.dup.freeze
|
|
190
|
+
@filter = keys.dup.freeze
|
|
191
|
+
@recursive = recursive
|
|
192
|
+
@buffer_size = bytes
|
|
193
|
+
@normalize = normalize
|
|
194
|
+
@mode = winloop ? :winloop : :standalone
|
|
195
|
+
@pending_old = nil # cross-call carry is NOT used; adjacent-only (§5.8)
|
|
196
|
+
|
|
197
|
+
start_pump(sched) if winloop
|
|
198
|
+
self
|
|
199
|
+
end
|
|
200
|
+
private :init_state
|
|
201
|
+
|
|
202
|
+
# ---- public accessors --------------------------------------------------
|
|
203
|
+
|
|
204
|
+
attr_reader :path, :buffer_size, :mode
|
|
205
|
+
|
|
206
|
+
def recursive? = @recursive
|
|
207
|
+
def filter = @filter
|
|
208
|
+
|
|
209
|
+
# ---- the blocking pull -------------------------------------------------
|
|
210
|
+
|
|
211
|
+
# Returns a non-empty Array of Winwatch::Event as soon as any are available,
|
|
212
|
+
# or nil on timeout. timeout: nil = forever; non-negative seconds otherwise
|
|
213
|
+
# (ArgumentError if negative); 0 is a non-blocking poll. Raises
|
|
214
|
+
# Winwatch::Closed if the watcher is (or becomes) closed.
|
|
215
|
+
def take(timeout: nil)
|
|
216
|
+
ms = Winwatch.ms_for(timeout)
|
|
217
|
+
@mode == :winloop ? take_winloop(ms) : take_standalone(ms)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Yield events one at a time, forever. Returns nil after a :gone event (the
|
|
221
|
+
# terminal event), or when the watcher is closed from elsewhere (it swallows
|
|
222
|
+
# the Closed raised by its own internal take). Block required.
|
|
223
|
+
def each
|
|
224
|
+
raise ArgumentError, "Winwatch::Watcher#each requires a block" unless block_given?
|
|
225
|
+
|
|
226
|
+
loop do
|
|
227
|
+
batch =
|
|
228
|
+
begin
|
|
229
|
+
take
|
|
230
|
+
rescue Closed
|
|
231
|
+
return nil
|
|
232
|
+
end
|
|
233
|
+
next if batch.nil?
|
|
234
|
+
|
|
235
|
+
batch.each do |event|
|
|
236
|
+
yield event
|
|
237
|
+
return nil if event.type == :gone
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# ---- standalone take ----------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def take_standalone(ms)
|
|
245
|
+
# Track a monotonic deadline for a finite wait so a benign housekeeping
|
|
246
|
+
# completion mid-wait (a 995 self-heal, a 745, or an aborted op that
|
|
247
|
+
# re-armed cleanly -> an empty classification) does NOT masquerade as a
|
|
248
|
+
# timeout: we keep waiting out the REMAINING time rather than returning nil
|
|
249
|
+
# the instant such a completion intervenes (§2.5/§5.17). nil only on the
|
|
250
|
+
# real deadline. Infinite waits (ms<0) loop forever as before.
|
|
251
|
+
deadline = ms.negative? ? nil : (monotonic + (ms / 1000.0))
|
|
252
|
+
slice = ms
|
|
253
|
+
loop do
|
|
254
|
+
result = Winwatch.run_blocking { _take(slice) }
|
|
255
|
+
case result
|
|
256
|
+
when nil
|
|
257
|
+
return nil # timeout
|
|
258
|
+
when :closed
|
|
259
|
+
raise Closed, "winwatch: watcher is closed"
|
|
260
|
+
else
|
|
261
|
+
data, code, rearm = result
|
|
262
|
+
events = classify(data, code, rearm)
|
|
263
|
+
unless events.empty?
|
|
264
|
+
# A terminal :gone auto-closes the watcher (§2.5): the next take
|
|
265
|
+
# raises Closed. Close after building the batch so this batch is
|
|
266
|
+
# still delivered.
|
|
267
|
+
close if events.any? { |e| e.type == :gone }
|
|
268
|
+
return events
|
|
269
|
+
end
|
|
270
|
+
# An empty result means "no event, re-armed, keep waiting". Loop
|
|
271
|
+
# forever on an infinite wait; on a finite deadline, recompute the
|
|
272
|
+
# remaining time and keep waiting until it is actually exhausted.
|
|
273
|
+
next if deadline.nil?
|
|
274
|
+
|
|
275
|
+
remaining = deadline - monotonic
|
|
276
|
+
return nil if remaining <= 0
|
|
277
|
+
|
|
278
|
+
slice = Winwatch.ms_for(remaining)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
private :take_standalone
|
|
283
|
+
|
|
284
|
+
# ---- :winloop take ------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
def take_winloop(ms)
|
|
287
|
+
deadline = ms.negative? ? nil : (monotonic + (ms / 1000.0))
|
|
288
|
+
@mutex.synchronize do
|
|
289
|
+
loop do
|
|
290
|
+
unless @events.empty?
|
|
291
|
+
batch = @events
|
|
292
|
+
@events = []
|
|
293
|
+
return batch
|
|
294
|
+
end
|
|
295
|
+
raise Closed, "winwatch: watcher is closed" if drained_and_dead?
|
|
296
|
+
|
|
297
|
+
remaining = remaining_slice(deadline)
|
|
298
|
+
return nil if remaining && remaining <= 0
|
|
299
|
+
|
|
300
|
+
@cond.wait(@mutex, remaining ? [remaining, 0.1].min : 0.1)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
private :take_winloop
|
|
305
|
+
|
|
306
|
+
# True once the queue is empty AND the watcher is closed or its pump is dead
|
|
307
|
+
# (so take/each raise Closed instead of parking forever, §6.1 dead-pump).
|
|
308
|
+
def drained_and_dead?
|
|
309
|
+
return false unless @events.empty?
|
|
310
|
+
|
|
311
|
+
@closed || pump_dead?
|
|
312
|
+
end
|
|
313
|
+
private :drained_and_dead?
|
|
314
|
+
|
|
315
|
+
def remaining_slice(deadline)
|
|
316
|
+
return nil if deadline.nil?
|
|
317
|
+
|
|
318
|
+
deadline - monotonic
|
|
319
|
+
end
|
|
320
|
+
private :remaining_slice
|
|
321
|
+
|
|
322
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
323
|
+
private :monotonic
|
|
324
|
+
|
|
325
|
+
# ---- close / closed? ---------------------------------------------------
|
|
326
|
+
|
|
327
|
+
# Cancel the in-flight op, close the directory handle, and wake any blocked
|
|
328
|
+
# take (which raises Closed). Pending not-yet-taken events are discarded.
|
|
329
|
+
# Idempotent; safe from any thread/fiber. After :gone the watcher is already
|
|
330
|
+
# closed; calling close again is a no-op.
|
|
331
|
+
def close
|
|
332
|
+
@mode == :winloop ? close_winloop : close_standalone
|
|
333
|
+
nil
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def close_standalone
|
|
337
|
+
_close_standalone
|
|
338
|
+
end
|
|
339
|
+
private :close_standalone
|
|
340
|
+
|
|
341
|
+
def close_winloop
|
|
342
|
+
@issue_lock.synchronize do
|
|
343
|
+
@closed = true
|
|
344
|
+
_mark_closed
|
|
345
|
+
_cancel
|
|
346
|
+
end
|
|
347
|
+
# Wake a parked consumer and wait (bounded) for the pump to finish so the
|
|
348
|
+
# handle is closed in the cancel -> reap -> close order. On a dead pump,
|
|
349
|
+
# fall through to _close_handle directly.
|
|
350
|
+
signal_consumers
|
|
351
|
+
wait_for_pump_done
|
|
352
|
+
nil
|
|
353
|
+
end
|
|
354
|
+
private :close_winloop
|
|
355
|
+
|
|
356
|
+
# closed? is defined in C (raw getter; works on closed objects).
|
|
357
|
+
|
|
358
|
+
# ===================================================================
|
|
359
|
+
# :winloop pump (runs on the loop thread; the only winloop-protocol caller)
|
|
360
|
+
# ===================================================================
|
|
361
|
+
|
|
362
|
+
def start_pump(sched)
|
|
363
|
+
@sched = sched
|
|
364
|
+
@loop_thread = Thread.current
|
|
365
|
+
@issue_lock = ::Mutex.new
|
|
366
|
+
@mutex = ::Mutex.new
|
|
367
|
+
@cond = ::ConditionVariable.new
|
|
368
|
+
@events = []
|
|
369
|
+
@closed = false
|
|
370
|
+
@pump_done = false
|
|
371
|
+
@first_issue_code = nil
|
|
372
|
+
|
|
373
|
+
# Associate our directory handle with the loop's IOCP. The scheduler's
|
|
374
|
+
# op_associate takes the raw HANDLE Integer; we expose it via a private
|
|
375
|
+
# primitive so winwatch's struct stays the single owner.
|
|
376
|
+
sched.op_associate(handle_value)
|
|
377
|
+
|
|
378
|
+
# Spawn the pump fiber; winloop's `fiber` hook resumes it immediately to
|
|
379
|
+
# its first park, so @first_issue_code is set by the time schedule returns.
|
|
380
|
+
Fiber.schedule { pump_loop }
|
|
381
|
+
|
|
382
|
+
# First-issue handoff (§6.1): raise on any nonzero open-time code.
|
|
383
|
+
code = @first_issue_code
|
|
384
|
+
return if code.nil? || code.zero?
|
|
385
|
+
|
|
386
|
+
_close_handle
|
|
387
|
+
raise_open_code(code)
|
|
388
|
+
end
|
|
389
|
+
private :start_pump
|
|
390
|
+
|
|
391
|
+
def handle_value
|
|
392
|
+
@handle_value ||= _handle_value
|
|
393
|
+
end
|
|
394
|
+
private :handle_value
|
|
395
|
+
|
|
396
|
+
def pump_loop
|
|
397
|
+
first = true
|
|
398
|
+
loop do
|
|
399
|
+
code = nil
|
|
400
|
+
op_id = nil
|
|
401
|
+
@issue_lock.synchronize do
|
|
402
|
+
if @closed
|
|
403
|
+
code = :closed
|
|
404
|
+
else
|
|
405
|
+
op_id, ov, buf = @sched.op_prepare(handle_value, capacity: @buffer_size)
|
|
406
|
+
code = _issue(ov, buf, @buffer_size)
|
|
407
|
+
if code.zero?
|
|
408
|
+
@sched.op_submitted(op_id)
|
|
409
|
+
else
|
|
410
|
+
@sched.op_abandon(op_id)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
break if code == :closed
|
|
415
|
+
|
|
416
|
+
# A successful issue clears the CONSECUTIVE re-issue-failure counter so a
|
|
417
|
+
# later, unrelated single ERROR_NOTIFY_ENUM_DIR cannot reach the :gone
|
|
418
|
+
# threshold off a stale count from a long-recovered earlier overflow
|
|
419
|
+
# (§5.0: "second consecutive failure -> :gone").
|
|
420
|
+
@issue_retry = 0 if code.zero?
|
|
421
|
+
|
|
422
|
+
if first
|
|
423
|
+
@first_issue_code = code
|
|
424
|
+
first = false
|
|
425
|
+
break unless code.zero? # open-time failure: watch raises, pump exits
|
|
426
|
+
elsif !code.zero?
|
|
427
|
+
# mid-life issue failure: :rescan retry-once / :gone (§5.0)
|
|
428
|
+
break if classify_issue_failure(code)
|
|
429
|
+
|
|
430
|
+
next # retry the issue
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
bytes, err, data = @sched.await_op(op_id) # parks OUTSIDE the lock; NO timeout
|
|
434
|
+
break unless handle_completion(bytes, err, data)
|
|
435
|
+
end
|
|
436
|
+
ensure
|
|
437
|
+
signal_pump_done
|
|
438
|
+
end
|
|
439
|
+
private :pump_loop
|
|
440
|
+
|
|
441
|
+
# Map a completion (bytes, error, data) to events on the @events queue.
|
|
442
|
+
# Returns true to keep pumping, false to stop (terminal / closed).
|
|
443
|
+
def handle_completion(bytes, err, data)
|
|
444
|
+
return false if @closed
|
|
445
|
+
|
|
446
|
+
events = classify(data, err.to_i, 0)
|
|
447
|
+
enqueue(events) unless events.empty?
|
|
448
|
+
if events.any? { |e| e.type == :gone }
|
|
449
|
+
# Terminal: auto-close so the next drained take raises Closed (§2.5).
|
|
450
|
+
# The :gone event is already queued for delivery; mark closed and close
|
|
451
|
+
# the handle (the op already completed terminally — nothing to cancel).
|
|
452
|
+
@closed = true
|
|
453
|
+
_mark_closed
|
|
454
|
+
_close_handle
|
|
455
|
+
return false
|
|
456
|
+
end
|
|
457
|
+
true
|
|
458
|
+
end
|
|
459
|
+
private :handle_completion
|
|
460
|
+
|
|
461
|
+
# Mid-life re-issue failure classification (returns true to STOP the pump).
|
|
462
|
+
def classify_issue_failure(code)
|
|
463
|
+
if code == Winwatch::ERROR_NOTIFY_ENUM_DIR
|
|
464
|
+
@issue_retry = (@issue_retry || 0) + 1
|
|
465
|
+
enqueue([Event.new(:rescan, @path, nil, nil).freeze])
|
|
466
|
+
return true if @issue_retry >= 2 # second consecutive failure -> :gone
|
|
467
|
+
|
|
468
|
+
false # retry the issue once
|
|
469
|
+
else
|
|
470
|
+
enqueue([Event.new(:gone, @path, nil, code).freeze])
|
|
471
|
+
@closed = true
|
|
472
|
+
true
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
private :classify_issue_failure
|
|
476
|
+
|
|
477
|
+
def enqueue(events)
|
|
478
|
+
@mutex.synchronize do
|
|
479
|
+
@events.concat(events)
|
|
480
|
+
@cond.broadcast
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
private :enqueue
|
|
484
|
+
|
|
485
|
+
def signal_consumers
|
|
486
|
+
@mutex.synchronize { @cond.broadcast }
|
|
487
|
+
end
|
|
488
|
+
private :signal_consumers
|
|
489
|
+
|
|
490
|
+
def signal_pump_done
|
|
491
|
+
@mutex.synchronize do
|
|
492
|
+
@pump_done = true
|
|
493
|
+
@cond.broadcast
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
private :signal_pump_done
|
|
497
|
+
|
|
498
|
+
# The pump is dead when its done flag is unset AND the loop is gone. Primary
|
|
499
|
+
# signal: the recorded loop thread is no longer alive (Winloop.run unwound
|
|
500
|
+
# and its thread finished). winloop's #closed? is NOT usable for this —
|
|
501
|
+
# under Winloop.run, #close IS the loop, so @sched.closed? is true for the
|
|
502
|
+
# whole run after the first park (it would falsely report every parked
|
|
503
|
+
# consumer's pump dead). So detection degrades to loop-thread liveness alone
|
|
504
|
+
# (§6.1 / open-risk 1: weaker, but correct for the worker-thread loop the
|
|
505
|
+
# tests and real callers use). As a secondary signal we still consult
|
|
506
|
+
# closed? ONLY when the loop thread is the current thread is false — i.e. we
|
|
507
|
+
# are NOT being driven by the loop right now and a closed scheduler then
|
|
508
|
+
# really means the loop is over.
|
|
509
|
+
def pump_dead?
|
|
510
|
+
return false if @pump_done
|
|
511
|
+
return true unless @loop_thread.alive?
|
|
512
|
+
return false if Thread.current.equal?(@loop_thread)
|
|
513
|
+
|
|
514
|
+
@sched.respond_to?(:closed?) && @sched.closed?
|
|
515
|
+
end
|
|
516
|
+
private :pump_dead?
|
|
517
|
+
|
|
518
|
+
def wait_for_pump_done
|
|
519
|
+
@mutex.synchronize do
|
|
520
|
+
loop do
|
|
521
|
+
return if @pump_done
|
|
522
|
+
|
|
523
|
+
if pump_dead?
|
|
524
|
+
# Loop already gone; the pump can never signal. Close directly.
|
|
525
|
+
_close_handle
|
|
526
|
+
@pump_done = true
|
|
527
|
+
return
|
|
528
|
+
end
|
|
529
|
+
@cond.wait(@mutex, 0.05)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
private :wait_for_pump_done
|
|
534
|
+
|
|
535
|
+
# ===================================================================
|
|
536
|
+
# Completion classification + event construction (one table, both modes)
|
|
537
|
+
# ===================================================================
|
|
538
|
+
|
|
539
|
+
# Map one (data, code, rearm_code) completion to an Array<Event> (§5.0).
|
|
540
|
+
# data is a binary String (raw batch) or nil; code is the completion's Win32
|
|
541
|
+
# code; rearm_code is the standalone re-arm result (0 in :winloop, handled by
|
|
542
|
+
# the pump). An empty Array means "no event; re-armed; keep waiting".
|
|
543
|
+
def classify(data, code, rearm_code)
|
|
544
|
+
if code.zero?
|
|
545
|
+
if data && !data.empty?
|
|
546
|
+
events = parse_to_events(data)
|
|
547
|
+
# A re-arm failure after a good batch is terminal: append :gone.
|
|
548
|
+
append_rearm_failure(events, rearm_code)
|
|
549
|
+
events
|
|
550
|
+
else
|
|
551
|
+
# zero-byte successful completion = overflow (success spelling)
|
|
552
|
+
rescan_then_rearm(rearm_code)
|
|
553
|
+
end
|
|
554
|
+
elsif code == Winwatch::ERROR_NOTIFY_ENUM_DIR
|
|
555
|
+
rescan_then_rearm(rearm_code)
|
|
556
|
+
elsif code == Winwatch::ERROR_OPERATION_ABORTED
|
|
557
|
+
# a cancel: ours (close) or thread rundown (§5.11)
|
|
558
|
+
return [] if closed? # silent shutdown (close will raise Closed)
|
|
559
|
+
|
|
560
|
+
# thread-rundown 995: no event of its own, BUT the re-arm that follows
|
|
561
|
+
# the cancel can still fail (root deleted / network dropped between the
|
|
562
|
+
# cancel and the re-issue). Honor rearm_code exactly as the data/1022
|
|
563
|
+
# branches do, so a permanently un-armed op becomes a terminal :gone
|
|
564
|
+
# (or a recoverable :rescan for 1022) instead of an infinite re-loop.
|
|
565
|
+
append_rearm_failure([], rearm_code) # [] unless the re-arm really failed
|
|
566
|
+
elsif code == Winwatch::ERROR_NOTIFY_CLEANUP
|
|
567
|
+
# our own handle close completed the op; silent shutdown unless a re-arm
|
|
568
|
+
# we attempted afterward failed for real (same rationale as 995).
|
|
569
|
+
append_rearm_failure([], rearm_code)
|
|
570
|
+
else
|
|
571
|
+
# 5 (delete-pending), 64, 56, 1450, ... watch died -> :gone + auto-close
|
|
572
|
+
[Event.new(:gone, @path, nil, code).freeze]
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
private :classify
|
|
576
|
+
|
|
577
|
+
def rescan_then_rearm(rearm_code)
|
|
578
|
+
events = [Event.new(:rescan, @path, nil, nil).freeze]
|
|
579
|
+
append_rearm_failure(events, rearm_code)
|
|
580
|
+
events
|
|
581
|
+
end
|
|
582
|
+
private :rescan_then_rearm
|
|
583
|
+
|
|
584
|
+
# A nonzero standalone re-arm code after we already produced events is
|
|
585
|
+
# terminal (the watch can no longer continue): append :gone (unless the
|
|
586
|
+
# re-arm failed only because the op was cancelled by us / a rundown). Always
|
|
587
|
+
# returns +events+ so callers can both mutate-and-return in one step (the 995
|
|
588
|
+
# / 745 branches pass a fresh []).
|
|
589
|
+
def append_rearm_failure(events, rearm_code)
|
|
590
|
+
return events if rearm_code.nil? || rearm_code.zero?
|
|
591
|
+
return events if rearm_code == Winwatch::ERROR_OPERATION_ABORTED
|
|
592
|
+
|
|
593
|
+
if rearm_code == Winwatch::ERROR_NOTIFY_ENUM_DIR
|
|
594
|
+
# re-arm hit overflow: surface a :rescan, the next take re-arms again
|
|
595
|
+
events << Event.new(:rescan, @path, nil, nil).freeze
|
|
596
|
+
else
|
|
597
|
+
events << Event.new(:gone, @path, nil, rearm_code).freeze
|
|
598
|
+
end
|
|
599
|
+
events
|
|
600
|
+
end
|
|
601
|
+
private :append_rearm_failure
|
|
602
|
+
|
|
603
|
+
# Turn parsed [action, name] records into events, with rename pairing
|
|
604
|
+
# (adjacent-only, never withholds; §5.8). A malformed-chain sentinel
|
|
605
|
+
# (ACTION_RESCAN) becomes a trailing :rescan.
|
|
606
|
+
def parse_to_events(data)
|
|
607
|
+
records = Winwatch._parse(data)
|
|
608
|
+
events = []
|
|
609
|
+
i = 0
|
|
610
|
+
while i < records.length
|
|
611
|
+
action, name = records[i]
|
|
612
|
+
case action
|
|
613
|
+
when Winwatch::ACTION_ADDED
|
|
614
|
+
events << make_event(:added, name)
|
|
615
|
+
when Winwatch::ACTION_REMOVED
|
|
616
|
+
events << make_event(:removed, name)
|
|
617
|
+
when Winwatch::ACTION_MODIFIED
|
|
618
|
+
events << make_event(:modified, name)
|
|
619
|
+
when Winwatch::ACTION_RENAMED_OLD_NAME
|
|
620
|
+
nxt = records[i + 1]
|
|
621
|
+
if nxt && nxt[0] == Winwatch::ACTION_RENAMED_NEW_NAME
|
|
622
|
+
events << make_rename(name, nxt[1])
|
|
623
|
+
i += 1 # consumed the NEW too
|
|
624
|
+
else
|
|
625
|
+
# OLD with no immediately-following NEW degrades to :removed (§5.8)
|
|
626
|
+
events << make_event(:removed, name)
|
|
627
|
+
end
|
|
628
|
+
when Winwatch::ACTION_RENAMED_NEW_NAME
|
|
629
|
+
# NEW with no immediately-preceding OLD degrades to :added (§5.8)
|
|
630
|
+
events << make_event(:added, name)
|
|
631
|
+
when Winwatch::ACTION_RESCAN
|
|
632
|
+
events << Event.new(:rescan, @path, nil, nil).freeze
|
|
633
|
+
end
|
|
634
|
+
i += 1
|
|
635
|
+
end
|
|
636
|
+
events
|
|
637
|
+
end
|
|
638
|
+
private :parse_to_events
|
|
639
|
+
|
|
640
|
+
def make_event(type, rel_name)
|
|
641
|
+
Event.new(type, join_path(rel_name), nil, nil).freeze
|
|
642
|
+
end
|
|
643
|
+
private :make_event
|
|
644
|
+
|
|
645
|
+
def make_rename(old_rel, new_rel)
|
|
646
|
+
Event.new(:renamed, join_path(new_rel), join_path(old_rel), nil).freeze
|
|
647
|
+
end
|
|
648
|
+
private :make_rename
|
|
649
|
+
|
|
650
|
+
# Join the watch root with a record's relative name: translate '\' -> '/',
|
|
651
|
+
# best-effort 8.3 normalization when normalize_names and a component has '~'.
|
|
652
|
+
def join_path(rel_name)
|
|
653
|
+
rel = rel_name.tr("\\", "/")
|
|
654
|
+
abs = "#{@path}/#{rel}"
|
|
655
|
+
abs = normalize_short(abs) if @normalize && rel_name.include?("~")
|
|
656
|
+
abs.freeze
|
|
657
|
+
end
|
|
658
|
+
private :join_path
|
|
659
|
+
|
|
660
|
+
def normalize_short(abs)
|
|
661
|
+
long = Winwatch._long_path(abs.tr("/", "\\"))
|
|
662
|
+
return abs if long.nil?
|
|
663
|
+
|
|
664
|
+
long.tr("\\", "/")
|
|
665
|
+
end
|
|
666
|
+
private :normalize_short
|
|
667
|
+
|
|
668
|
+
def raise_open_code(code)
|
|
669
|
+
case code
|
|
670
|
+
when 1 then raise Unsupported.new_with_code("ReadDirectoryChangesW", code)
|
|
671
|
+
when 2, 3 then raise NotFound.new_with_code("ReadDirectoryChangesW", code)
|
|
672
|
+
when 5 then raise AccessDenied.new_with_code("ReadDirectoryChangesW", code)
|
|
673
|
+
else raise OSError.new_with_code("ReadDirectoryChangesW", code)
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
private :raise_open_code
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# OSError subclasses get a tiny ctor so the :winloop first-issue handoff can
|
|
680
|
+
# raise the same shape the C open path would (message + @code).
|
|
681
|
+
class OSError
|
|
682
|
+
def self.new_with_code(api, code)
|
|
683
|
+
exc = new("#{api} failed (error #{code})")
|
|
684
|
+
exc.instance_variable_set(:@code, code)
|
|
685
|
+
exc
|
|
686
|
+
end
|
|
687
|
+
end
|
|
688
|
+
end
|