cool.io 1.9.4 → 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.4
4
+ version: 1.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tony Arcieri
@@ -77,6 +77,7 @@ extensions:
77
77
  extra_rdoc_files: []
78
78
  files:
79
79
  - ".github/workflows/test.yaml"
80
+ - ".github/workflows/valgrind.yaml"
80
81
  - ".gitignore"
81
82
  - ".rspec"
82
83
  - CHANGES.md
@@ -118,6 +119,7 @@ files:
118
119
  - ext/libev/ev_select.c
119
120
  - ext/libev/ev_vars.h
120
121
  - ext/libev/ev_win32.c
122
+ - ext/libev/ev_win32_stat.c
121
123
  - ext/libev/ev_wrap.h
122
124
  - ext/libev/test_libev_win32.c
123
125
  - gems.rb
@@ -142,7 +144,9 @@ files:
142
144
  - spec/async_watcher_spec.rb
143
145
  - spec/detach_race_condition_spec.rb
144
146
  - spec/dns_spec.rb
147
+ - spec/fixtures/windows_stat_native.c
145
148
  - spec/iobuffer_spec.rb
149
+ - spec/loop_spec.rb
146
150
  - spec/spec_helper.rb
147
151
  - spec/stat_watcher_spec.rb
148
152
  - spec/tcp_server_spec.rb
@@ -151,6 +155,9 @@ files:
151
155
  - spec/udp_socket_spec.rb
152
156
  - spec/unix_listener_spec.rb
153
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
154
161
  homepage: https://github.com/socketry/cool.io
155
162
  licenses:
156
163
  - MIT
@@ -169,14 +176,16 @@ required_rubygems_version: !ruby/object:Gem::Requirement
169
176
  - !ruby/object:Gem::Version
170
177
  version: '0'
171
178
  requirements: []
172
- rubygems_version: 4.0.6
179
+ rubygems_version: 4.0.20
173
180
  specification_version: 4
174
181
  summary: A cool framework for doing high performance I/O in Ruby
175
182
  test_files:
176
183
  - spec/async_watcher_spec.rb
177
184
  - spec/detach_race_condition_spec.rb
178
185
  - spec/dns_spec.rb
186
+ - spec/fixtures/windows_stat_native.c
179
187
  - spec/iobuffer_spec.rb
188
+ - spec/loop_spec.rb
180
189
  - spec/spec_helper.rb
181
190
  - spec/stat_watcher_spec.rb
182
191
  - spec/tcp_server_spec.rb
@@ -185,3 +194,6 @@ test_files:
185
194
  - spec/udp_socket_spec.rb
186
195
  - spec/unix_listener_spec.rb
187
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