cool.io 1.9.5 → 1.10.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,363 @@
1
+ require File.expand_path('../spec_helper', __FILE__)
2
+ require 'tmpdir'
3
+ require 'fileutils'
4
+ require 'rbconfig'
5
+
6
+ describe 'Windows notification-backed StatWatcher', :if => RUBY_PLATFORM =~ /mingw|mswin/ do
7
+ before do
8
+ @directory = Dir.mktmpdir('coolio-notify-')
9
+ @loop = Coolio::Loop.new
10
+ @watchers = []
11
+ @threads = []
12
+ end
13
+
14
+ after do
15
+ @threads.each(&:join)
16
+ @watchers.each { |watcher| watcher.detach if watcher.attached? }
17
+ FileUtils.remove_entry(@directory)
18
+ @loop = nil
19
+ @watchers = nil
20
+ GC.start # exercise IOCP cancellation and loop destruction
21
+ end
22
+
23
+ def watch(path, interval = 60)
24
+ watcher = Coolio::StatWatcher.new(path, interval)
25
+ watcher.attach(@loop)
26
+ @watchers << watcher
27
+ watcher
28
+ end
29
+
30
+ def root_file
31
+ path = File.join(File.expand_path('/'), "coolio-#{File.basename(@directory)}")
32
+ begin
33
+ File.write(path, 'a')
34
+ rescue Errno::EACCES, Errno::EPERM, Errno::EROFS
35
+ skip 'requires write access to the drive root'
36
+ end
37
+ path
38
+ end
39
+
40
+ # With a 60-second stat interval, a callback within this deadline must come
41
+ # from native notifications. Mutate from another thread while select waits.
42
+ def await_change(watcher, size: nil, exists: nil, &mutation)
43
+ change = nil
44
+ first_previous = nil
45
+ reactor = @loop
46
+ callback_thread = nil
47
+ stopped = false
48
+ stop = -> do
49
+ unless stopped
50
+ stopped = true
51
+ reactor.stop
52
+ end
53
+ end
54
+ watcher.define_singleton_method(:on_change) do |previous, current|
55
+ first_previous ||= previous
56
+ if (size.nil? || current.size == size) && (exists.nil? || (current.nlink != 0) == exists)
57
+ change = [first_previous, current]
58
+ callback_thread = Thread.current
59
+ stop.call
60
+ end
61
+ end
62
+ deadline = Coolio::TimerWatcher.new(2, false)
63
+ deadline.on_timer { stop.call }
64
+ deadline.attach(@loop)
65
+ @threads << Thread.new { sleep 0.1; mutation.call }
66
+ @loop.run
67
+ expect(change).not_to be_nil
68
+ expect(callback_thread).to eq(Thread.current)
69
+ change
70
+ ensure
71
+ deadline.detach if deadline && deadline.attached?
72
+ @threads.each(&:join)
73
+ end
74
+
75
+ it 'wakes a blocked loop for a file size change without waiting for polling' do
76
+ path = File.join(@directory, 'file')
77
+ File.write(path, 'a')
78
+ previous, current = await_change(watch(path), size: 6) { File.write(path, 'longer') }
79
+ expect(previous.size).to eq(1)
80
+ expect(current.size).to eq(6)
81
+ end
82
+
83
+ it 'notifies for creation, deletion, and recreation of a watched file' do
84
+ path = File.join(@directory, 'file')
85
+ watcher = watch(path)
86
+ previous, current = await_change(watcher, size: 7, exists: true) { File.write(path, 'created') }
87
+ expect(previous.nlink).to eq(0)
88
+ expect(current.nlink).not_to eq(0)
89
+ _, current = await_change(watcher, exists: false) { File.delete(path) }
90
+ expect(current.nlink).to eq(0)
91
+ _, current = await_change(watcher, size: 9, exists: true) { File.write(path, 'recreated') }
92
+ expect(current.size).to eq(9)
93
+ end
94
+
95
+ it 'notifies when a watched file is renamed away' do
96
+ path = File.join(@directory, 'file')
97
+ File.write(path, 'a')
98
+ _, current = await_change(watch(path)) { File.rename(path, "#{path}.old") }
99
+ expect(current.nlink).to eq(0)
100
+ end
101
+
102
+ it 'supports Unicode parent directories and filenames' do
103
+ directory = File.join(@directory, "\u76e3\u8996")
104
+ Dir.mkdir(directory)
105
+ path = File.join(directory, "\u5909\u66f4.txt")
106
+ File.write(path, 'a')
107
+ _, current = await_change(watch(path), size: 6) { File.write(path, 'longer') }
108
+ expect(current.size).to eq(6)
109
+ end
110
+
111
+ it 'matches notification names using Windows Unicode case folding' do
112
+ path = File.join(@directory, "\u00c4.txt")
113
+ File.write(path, 'a')
114
+ folded = File.join(@directory, "\u00e4.txt")
115
+ skip 'requires a case-insensitive directory' unless File.exist?(folded)
116
+ watcher = watch(folded)
117
+ _, current = await_change(watcher, size: 6) { File.write(path, 'longer') }
118
+ expect(current.size).to eq(6)
119
+ end
120
+
121
+ it 'notifies for an extended-length file path below a directory' do
122
+ path = File.join(@directory, 'file')
123
+ File.write(path, 'a')
124
+ extended = "\\\\?\\#{path.tr('/', '\\')}"
125
+ _, current = await_change(watch(extended), size: 6) { File.write(path, 'longer') }
126
+ expect(current.size).to eq(6)
127
+ end
128
+
129
+ it 'notifies for an extended-length file path at the drive root' do
130
+ path = root_file
131
+ extended = "\\\\?\\#{path.tr('/', '\\')}"
132
+ _, current = await_change(watch(extended), size: 6) { File.write(path, 'longer') }
133
+ expect(current.size).to eq(6)
134
+ ensure
135
+ File.delete(path) if path && File.exist?(path)
136
+ end
137
+
138
+ it 'notifies for an extended-length drive root directory' do
139
+ path = root_file
140
+ child = "#{path}.child"
141
+ root = "\\\\?\\#{File.expand_path('/').tr('/', '\\')}"
142
+ watcher = watch(root)
143
+ sleep 1.1 # Directory timestamps are exposed as whole seconds.
144
+ await_change(watcher) { File.write(child, 'a') }
145
+ ensure
146
+ [path, child].compact.each { |file| File.delete(file) if File.exist?(file) }
147
+ end
148
+
149
+ it 'continues monitoring a shared directory after another watcher detaches' do
150
+ first = File.join(@directory, 'first')
151
+ second = File.join(@directory, 'second')
152
+ File.write(first, 'a')
153
+ File.write(second, 'a')
154
+ first_watcher = watch(first)
155
+ second_watcher = watch(second)
156
+ first_watcher.detach
157
+ _, current = await_change(second_watcher, size: 6) { File.write(second, 'longer') }
158
+ expect(current.size).to eq(6)
159
+ end
160
+
161
+ it 'transcodes a Windows-31J path for the wide Windows APIs' do
162
+ path = File.join(@directory, "\u5909\u66f4.txt")
163
+ File.write(path, 'a')
164
+ watcher = watch(path.encode('Windows-31J'))
165
+ _, current = await_change(watcher, size: 6) { File.write(path, 'longer') }
166
+ expect(current.size).to eq(6)
167
+ end
168
+
169
+ it 'transfers notification monitoring to another loop' do
170
+ path = File.join(@directory, 'file')
171
+ File.write(path, 'a')
172
+ watcher = watch(path)
173
+ old_loop = @loop
174
+ @loop = Coolio::Loop.new
175
+ watcher.attach(@loop)
176
+ old_loop.run_nonblock
177
+ _, current = await_change(watcher, size: 6) { File.write(path, 'longer') }
178
+ expect(current.size).to eq(6)
179
+ end
180
+
181
+ it 'does not dispatch a queued change after disable and supports re-enable' do
182
+ path = File.join(@directory, 'file')
183
+ File.write(path, 'a')
184
+ watcher = watch(path)
185
+ calls = 0
186
+ watcher.define_singleton_method(:on_change) { |_, _| calls += 1 }
187
+ File.write(path, 'queued')
188
+ sleep 0.05
189
+ watcher.disable
190
+ @loop.run_nonblock
191
+ expect(calls).to eq(0)
192
+ watcher.enable
193
+ _, current = await_change(watcher, size: 12) { File.write(path, 'after enable') }
194
+ expect(current.size).to eq(12)
195
+ end
196
+
197
+ it 'falls back to polling when the parent is missing, then registers notifications' do
198
+ directory = File.join(@directory, 'missing')
199
+ path = File.join(directory, 'file')
200
+ watcher = watch(path, 0.1)
201
+ _, current = await_change(watcher, size: 7, exists: true) { Dir.mkdir(directory); File.write(path, 'created') }
202
+ expect(current.size).to eq(7)
203
+ # Reattach with a long interval to prove the now-existing parent is watched.
204
+ watcher.detach
205
+ _, current = await_change(watch(path), size: 18) { File.write(path, 'longer than before') }
206
+ expect(current.size).to eq(18)
207
+ end
208
+
209
+ it 'handles directory entry changes' do
210
+ directory = File.join(@directory, 'directory')
211
+ Dir.mkdir(directory)
212
+ watcher = watch(directory)
213
+ # StatInfo exposes whole seconds for timestamps; cross a second boundary
214
+ # so this directory-only change is observable through the existing API.
215
+ sleep 1.1
216
+ await_change(watcher) { File.write(File.join(directory, 'child'), 'a') }
217
+ end
218
+
219
+ it 'recovers after a parent directory is moved and replaced' do
220
+ directory = File.join(@directory, 'parent')
221
+ Dir.mkdir(directory)
222
+ path = File.join(directory, 'file')
223
+ File.write(path, 'a')
224
+ watcher = watch(path, 0.1)
225
+ _, current = await_change(watcher, size: 11, exists: true) do
226
+ File.rename(directory, "#{directory}.old")
227
+ Dir.mkdir(directory)
228
+ File.write(path, 'replacement')
229
+ end
230
+ expect(current.size).to eq(11)
231
+ _, current = await_change(watcher, size: 5) { File.write(path, 'final') }
232
+ expect(current.size).to eq(5)
233
+ end
234
+
235
+ it 'keeps monitoring after an existing file is replaced' do
236
+ path = File.join(@directory, 'long-watched-filename.log')
237
+ replacement = File.join(@directory, 'replacement.log')
238
+ File.write(path, 'a')
239
+ File.write(replacement, 'replacement')
240
+ watcher = watch(path)
241
+ await_change(watcher, size: 11, exists: true) do
242
+ File.rename(path, "#{path}.old")
243
+ File.rename(replacement, path)
244
+ end
245
+ await_change(watcher, size: 12) { File.write(path, 'x', mode: 'a') }
246
+ end
247
+
248
+ it 'coalesces a burst of notifications and observes the final state' do
249
+ path = File.join(@directory, 'file')
250
+ File.write(path, 'a')
251
+ watcher = watch(path)
252
+ 200.times do |index|
253
+ File.write(path, 'x' * (index + 2))
254
+ end
255
+ _, current = await_change(watcher, size: 5) { File.write(path, 'final') }
256
+ expect(current.size).to eq(5)
257
+ end
258
+
259
+ it 'validates a shared directory handle before attaching a new watcher' do
260
+ directory = File.join(@directory, 'parent')
261
+ Dir.mkdir(directory)
262
+ first = File.join(directory, 'first')
263
+ File.write(first, 'a')
264
+ watch(first)
265
+
266
+ File.rename(directory, "#{directory}.old")
267
+ Dir.mkdir(directory)
268
+ second = File.join(directory, 'second')
269
+ File.write(second, 'a')
270
+ _, current = await_change(watch(second), size: 5) { File.write(second, 'final') }
271
+ expect(current.size).to eq(5)
272
+ end
273
+
274
+ it 'keeps timers and Ruby threads responsive during sustained notifications' do
275
+ observed_size = nil
276
+ paths = 1000.times.map do |index|
277
+ path = File.join(@directory, "#{index}.log")
278
+ File.write(path, 'a')
279
+ watch(path).define_singleton_method(:on_change) do |_, current|
280
+ observed_size = current.size if index.zero?
281
+ end
282
+ path
283
+ end
284
+ clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
285
+ timer_gaps = []
286
+ thread_gaps = []
287
+ child = status = final_size = previous = deadline_at = nil
288
+ timer = Coolio::TimerWatcher.new(0.1, true)
289
+ timer.on_timer do
290
+ now = clock.call
291
+ timer_gaps << now - previous
292
+ previous = now
293
+ if child && (result = Process.wait2(child, Process::WNOHANG))
294
+ _, status = result
295
+ child = nil
296
+ final_size = File.size(paths.first)
297
+ end
298
+ # Process startup and notification delivery can outlast a fixed window
299
+ # on CI. Keep dispatching until the writer exits and its final size arrives.
300
+ @loop.stop if (status && observed_size == final_size) || now >= deadline_at
301
+ end
302
+ timer.attach(@loop)
303
+ @watchers << timer
304
+ done = false
305
+ # Use a separate process: a Ruby writer thread would itself be blocked by
306
+ # the GVL while the notification callback is draining the dirty set.
307
+ writer = <<~'RUBY'
308
+ path = ARGV.fetch(0)
309
+ sleep 0.3
310
+ clock = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
311
+ start = clock.call
312
+ writes = 0
313
+ while clock.call - start < 3
314
+ File.write(path, 'x', mode: 'a')
315
+ writes += 1
316
+ sleep [start + writes.fdiv(64) - clock.call, 0].max
317
+ end
318
+ RUBY
319
+ child = Process.spawn(RbConfig.ruby, '-e', writer, paths.first)
320
+ @threads << Thread.new do
321
+ last = clock.call
322
+ until done
323
+ sleep 0.05
324
+ now = clock.call
325
+ thread_gaps << now - last
326
+ last = now
327
+ end
328
+ end
329
+ previous = clock.call
330
+ deadline_at = previous + 15
331
+ @loop.run
332
+ done = true
333
+ @threads.each(&:join)
334
+ expect(status).not_to be_nil
335
+ expect(status.success?).to eq(true)
336
+ expect(timer_gaps.max).to be < 2
337
+ expect(thread_gaps.max).to be < 2
338
+ expect(final_size).to be > 1
339
+ expect(observed_size).to eq(final_size)
340
+ ensure
341
+ done = true
342
+ if child
343
+ begin
344
+ Process.kill('KILL', child)
345
+ rescue Errno::ESRCH
346
+ end
347
+ Process.wait(child)
348
+ end
349
+ end
350
+
351
+ it 'can repeatedly detach and reattach while changes are pending' do
352
+ path = File.join(@directory, 'file')
353
+ File.write(path, 'a')
354
+ watcher = watch(path)
355
+ 50.times do |index|
356
+ File.write(path, 'x' * (index + 2))
357
+ watcher.detach
358
+ watcher.attach(@loop)
359
+ end
360
+ _, current = await_change(watcher, size: 5) { File.write(path, 'final') }
361
+ expect(current.size).to eq(5)
362
+ end
363
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cool.io
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.9.5
4
+ version: 1.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tony Arcieri
@@ -119,6 +119,7 @@ files:
119
119
  - ext/libev/ev_select.c
120
120
  - ext/libev/ev_vars.h
121
121
  - ext/libev/ev_win32.c
122
+ - ext/libev/ev_win32_stat.c
122
123
  - ext/libev/ev_wrap.h
123
124
  - ext/libev/test_libev_win32.c
124
125
  - gems.rb
@@ -143,7 +144,9 @@ files:
143
144
  - spec/async_watcher_spec.rb
144
145
  - spec/detach_race_condition_spec.rb
145
146
  - spec/dns_spec.rb
147
+ - spec/fixtures/windows_stat_native.c
146
148
  - spec/iobuffer_spec.rb
149
+ - spec/loop_spec.rb
147
150
  - spec/spec_helper.rb
148
151
  - spec/stat_watcher_spec.rb
149
152
  - spec/tcp_server_spec.rb
@@ -152,6 +155,9 @@ files:
152
155
  - spec/udp_socket_spec.rb
153
156
  - spec/unix_listener_spec.rb
154
157
  - spec/unix_server_spec.rb
158
+ - spec/watcher_reattach_spec.rb
159
+ - spec/windows_stat_native_spec.rb
160
+ - spec/windows_stat_watcher_spec.rb
155
161
  homepage: https://github.com/socketry/cool.io
156
162
  licenses:
157
163
  - MIT
@@ -170,14 +176,16 @@ required_rubygems_version: !ruby/object:Gem::Requirement
170
176
  - !ruby/object:Gem::Version
171
177
  version: '0'
172
178
  requirements: []
173
- rubygems_version: 4.0.16
179
+ rubygems_version: 4.0.20
174
180
  specification_version: 4
175
181
  summary: A cool framework for doing high performance I/O in Ruby
176
182
  test_files:
177
183
  - spec/async_watcher_spec.rb
178
184
  - spec/detach_race_condition_spec.rb
179
185
  - spec/dns_spec.rb
186
+ - spec/fixtures/windows_stat_native.c
180
187
  - spec/iobuffer_spec.rb
188
+ - spec/loop_spec.rb
181
189
  - spec/spec_helper.rb
182
190
  - spec/stat_watcher_spec.rb
183
191
  - spec/tcp_server_spec.rb
@@ -186,3 +194,6 @@ test_files:
186
194
  - spec/udp_socket_spec.rb
187
195
  - spec/unix_listener_spec.rb
188
196
  - spec/unix_server_spec.rb
197
+ - spec/watcher_reattach_spec.rb
198
+ - spec/windows_stat_native_spec.rb
199
+ - spec/windows_stat_watcher_spec.rb