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.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Winwatch
4
+ VERSION = "0.1.0"
5
+ end
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