curb 1.2.2 → 1.3.2

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.
@@ -12,6 +12,51 @@ end
12
12
  # by running multiple requests concurrently in a single thread using the Async gem.
13
13
  class TestCurbFiberScheduler < Test::Unit::TestCase
14
14
  include BugTestServerSetupTeardown
15
+ include TestServerMethods
16
+
17
+ class RecordingScheduler
18
+ attr_reader :io_wait_events
19
+
20
+ def initialize
21
+ @io_wait_events = []
22
+ end
23
+
24
+ def fiber(&block)
25
+ Fiber.new(blocking: false, &block)
26
+ end
27
+
28
+ def io_wait(io, events, timeout = nil)
29
+ @io_wait_events << events
30
+ raise TypeError, "expected Integer events, got #{events.class}" unless events.is_a?(Integer)
31
+
32
+ readers = (events & IO::READABLE) != 0 ? [io] : nil
33
+ writers = (events & IO::WRITABLE) != 0 ? [io] : nil
34
+ readable, writable = IO.select(readers, writers, nil, timeout)
35
+
36
+ ready = 0
37
+ ready |= IO::READABLE if readable && !readable.empty?
38
+ ready |= IO::WRITABLE if writable && !writable.empty?
39
+ ready.zero? ? false : ready
40
+ end
41
+
42
+ def kernel_sleep(duration = nil)
43
+ sleep(duration || 0)
44
+ end
45
+
46
+ def block(_blocker, timeout = nil)
47
+ sleep(timeout || 0)
48
+ false
49
+ end
50
+
51
+ def unblock(*)
52
+ end
53
+
54
+ def close
55
+ end
56
+
57
+ def fiber_interrupt(*)
58
+ end
59
+ end
15
60
 
16
61
  ITERS = 4
17
62
  MIN_S = 0.25
@@ -50,6 +95,8 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
50
95
  m.add(c)
51
96
  end
52
97
  m.perform
98
+ ensure
99
+ m.close if m
53
100
  end
54
101
 
55
102
  duration = Time.now - started
@@ -109,6 +156,8 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
109
156
  m.perform do
110
157
  yielded += 1
111
158
  end
159
+ ensure
160
+ m.close if m
112
161
  end
113
162
 
114
163
  assert_operator yielded, :>=, 1, 'perform did not yield block while waiting under scheduler'
@@ -130,20 +179,49 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
130
179
  c.on_complete { result = c.code }
131
180
  m.add(c)
132
181
  m.perform
182
+ ensure
183
+ m.close if m
133
184
  end
134
185
 
135
186
  assert_equal 200, result
136
187
  end
137
188
 
189
+ def test_multi_single_request_socket_perform_passes_integer_events_to_io_wait
190
+ omit('Skipping custom scheduler test on Windows') if WINDOWS
191
+ omit('Fiber scheduler API unavailable on this Ruby') unless fiber_scheduler_supported?
192
+
193
+ url = "http://127.0.0.1:#{@port}/test"
194
+ scheduler = RecordingScheduler.new
195
+ result = nil
196
+
197
+ with_scheduler(scheduler) do
198
+ m = Curl::Multi.new
199
+ c = Curl::Easy.new(url)
200
+ c.on_complete { result = c.code }
201
+ m.add(c)
202
+ unless m.respond_to?(:_socket_perform, true)
203
+ omit('socket-action perform path is not available in this build')
204
+ end
205
+ m.send(:_socket_perform)
206
+ ensure
207
+ m.close if m
208
+ end
209
+
210
+ assert_equal 200, result
211
+ assert_operator scheduler.io_wait_events.length, :>=, 1
212
+ assert scheduler.io_wait_events.all? { |events| events.is_a?(Integer) }
213
+ assert scheduler.io_wait_events.any? { |events| (events & (IO::READABLE | IO::WRITABLE)) != 0 }
214
+ end
215
+
138
216
  def test_multi_reuse_after_scheduler_perform
139
217
  unless HAS_ASYNC
140
218
  warn 'Skipping fiber scheduler test (Async gem not available)'
141
219
  return
142
220
  end
143
-
221
+
144
222
  url = "http://127.0.0.1:#{@port}/test"
145
223
  results = []
146
-
224
+
147
225
  async_run do
148
226
  m = Curl::Multi.new
149
227
  # First round
@@ -151,15 +229,224 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
151
229
  c1.on_complete { results << c1.code }
152
230
  m.add(c1)
153
231
  m.perform
154
-
232
+
155
233
  # Second round on same multi
156
234
  c2 = Curl::Easy.new(url)
157
235
  c2.on_complete { results << c2.code }
158
236
  m.add(c2)
159
237
  m.perform
238
+ ensure
239
+ m.close if m
160
240
  end
241
+
242
+ assert_equal [200, 200], results
243
+ end
161
244
 
245
+ def test_easy_perform_reuses_scheduler_multi_when_autoclose_is_enabled
246
+ if skip_no_async
247
+ return
248
+ end
249
+
250
+ url = "http://127.0.0.1:#{@port}/test"
251
+ results = []
252
+ previous_autoclose = Curl::Multi.autoclose
253
+ Curl::Multi.autoclose = true
254
+
255
+ async_run do
256
+ 2.times do
257
+ easy = Curl::Easy.new(url)
258
+ easy.perform
259
+ results << easy.code
260
+ end
261
+ end
262
+
162
263
  assert_equal [200, 200], results
264
+ ensure
265
+ Curl::Multi.autoclose = previous_autoclose if defined?(previous_autoclose)
266
+ cleanup_scheduler_state
267
+ end
268
+
269
+ def test_easy_perform_reraises_on_body_exception_under_scheduler
270
+ if skip_no_async
271
+ return
272
+ end
273
+
274
+ url = "http://127.0.0.1:#{@port}/test"
275
+
276
+ async_run do |top|
277
+ task = top.async do
278
+ c = Curl::Easy.new(url)
279
+ c.on_body { raise "body blew up" }
280
+ c.on_complete { sleep 0 }
281
+ c.perform
282
+ end
283
+
284
+ error = assert_raise(RuntimeError) do
285
+ task.wait
286
+ end
287
+
288
+ assert_equal "body blew up", error.message
289
+ end
290
+ end
291
+
292
+ def test_easy_perform_reraises_on_header_exception_under_scheduler
293
+ if skip_no_async
294
+ return
295
+ end
296
+
297
+ url = "http://127.0.0.1:#{@port}/test"
298
+
299
+ async_run do |top|
300
+ task = top.async do
301
+ c = Curl::Easy.new(url)
302
+ c.on_header { raise "header blew up" }
303
+ c.on_complete { sleep 0 }
304
+ c.perform
305
+ end
306
+
307
+ error = assert_raise(RuntimeError) do
308
+ task.wait
309
+ end
310
+
311
+ assert_equal "header blew up", error.message
312
+ end
313
+ end
314
+
315
+ def test_easy_perform_does_not_reraise_sibling_on_complete_exception_under_scheduler
316
+ if skip_no_async
317
+ return
318
+ end
319
+
320
+ with_ephemeral_http_server do |port, hits|
321
+ results = {}
322
+
323
+ async_run do |top|
324
+ successful = top.async do
325
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/fast")
326
+ easy.perform
327
+ results[:successful] = easy.response_code
328
+ rescue => e
329
+ results[:successful] = e
330
+ end
331
+
332
+ failing = top.async do
333
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/slow")
334
+ easy.on_complete { raise "boom" }
335
+ easy.perform
336
+ results[:failing] = :returned
337
+ rescue => e
338
+ results[:failing] = e
339
+ end
340
+
341
+ successful.wait
342
+ failing.wait
343
+ end
344
+
345
+ assert_equal 200, results[:successful], "successful scheduler peer should return normally"
346
+ assert_kind_of Curl::Err::AbortedByCallbackError, results[:failing]
347
+ assert_equal "boom", results[:failing].message
348
+ assert_equal 1, hits[:fast]
349
+ assert_equal 1, hits[:slow]
350
+ end
351
+ end
352
+
353
+ def test_easy_perform_does_not_reraise_fast_on_complete_exception_into_slow_successful_peer_under_scheduler
354
+ if skip_no_async
355
+ return
356
+ end
357
+
358
+ with_ephemeral_http_server do |port, hits|
359
+ results = {}
360
+
361
+ async_run do |top|
362
+ failing = top.async do
363
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/fast")
364
+ easy.on_complete { raise "boom" }
365
+ easy.perform
366
+ results[:failing] = :returned
367
+ rescue => e
368
+ results[:failing] = e
369
+ end
370
+
371
+ successful = top.async do
372
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/slow")
373
+ easy.perform
374
+ results[:successful] = easy.response_code
375
+ rescue => e
376
+ results[:successful] = e
377
+ end
378
+
379
+ failing.wait
380
+ successful.wait
381
+ end
382
+
383
+ assert_equal 200, results[:successful], "slow successful scheduler peer should not inherit a fast sibling callback error"
384
+ assert_kind_of Curl::Err::AbortedByCallbackError, results[:failing]
385
+ assert_equal "boom", results[:failing].message
386
+ assert_equal 1, hits[:fast]
387
+ assert_equal 1, hits[:slow]
388
+ end
389
+ end
390
+
391
+ def test_easy_perform_keeps_scheduler_timers_running_while_draining_deferred_on_complete_exception
392
+ if skip_no_async
393
+ return
394
+ end
395
+
396
+ with_ephemeral_http_server do |port, hits|
397
+ marks = {}
398
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
399
+
400
+ async_run do |top|
401
+ timer = top.async do
402
+ sleep 0.05
403
+ marks[:timer] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
404
+ end
405
+
406
+ failing = top.async do
407
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/fast")
408
+ easy.on_complete { raise "boom" }
409
+ easy.perform
410
+ marks[:failing] = :returned
411
+ rescue => e
412
+ marks[:failing] = e
413
+ end
414
+
415
+ successful = top.async do
416
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/slow")
417
+ easy.perform
418
+ marks[:slow_done] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
419
+ end
420
+
421
+ timer.wait
422
+ failing.wait
423
+ successful.wait
424
+ end
425
+
426
+ assert_kind_of Curl::Err::AbortedByCallbackError, marks[:failing]
427
+ assert_operator marks[:timer], :<, marks[:slow_done] - 0.02,
428
+ "scheduler timer should fire before the slow sibling finishes draining"
429
+ assert_equal 1, hits[:fast]
430
+ assert_equal 1, hits[:slow]
431
+ end
432
+ end
433
+
434
+ def test_drain_scheduler_pending_does_not_drop_work_rejected_during_deferred_abort
435
+ state = Curl.scheduler_state
436
+ easy = Curl::Easy.new("http://127.0.0.1:#{@port}/test")
437
+ waiter = {completed: false, done: false, error: nil}
438
+
439
+ state[:waiters][easy.object_id] = waiter
440
+ state[:pending] << easy
441
+ state[:multi].instance_variable_set(:@__curb_deferred_exception, RuntimeError.new("boom"))
442
+
443
+ Curl.drain_scheduler_pending(state)
444
+
445
+ assert(state[:pending].include?(easy) || waiter[:error],
446
+ "scheduler enqueue rejected during deferred abort should remain pending or fail its waiter instead of disappearing")
447
+ assert_equal 0, state[:multi].requests.length
448
+ ensure
449
+ cleanup_scheduler_state
163
450
  end
164
451
 
165
452
  private
@@ -168,6 +455,10 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
168
455
  warn 'Skipping fiber scheduler tests on Windows'
169
456
  return true
170
457
  end
458
+ unless fiber_scheduler_supported?
459
+ warn 'Skipping fiber scheduler tests (Fiber scheduler API unavailable)'
460
+ return true
461
+ end
171
462
  unless HAS_ASYNC
172
463
  warn 'Skipping fiber scheduler test (Async gem not available)'
173
464
  return true
@@ -183,8 +474,52 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
183
474
  Async(&block)
184
475
  end
185
476
  end
186
- end
187
- if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1')
188
- warn 'Skipping fiber scheduler tests on Ruby < 3.1'
189
- return
477
+
478
+ def fiber_scheduler_supported?
479
+ Fiber.respond_to?(:set_scheduler) && Fiber.respond_to?(:schedule)
480
+ end
481
+
482
+ def with_scheduler(scheduler)
483
+ previous_scheduler = Fiber.scheduler if Fiber.respond_to?(:scheduler)
484
+ Fiber.set_scheduler(scheduler)
485
+ fiber = Fiber.schedule { yield }
486
+ fiber.resume while fiber.alive?
487
+ ensure
488
+ Fiber.set_scheduler(previous_scheduler) if Fiber.respond_to?(:set_scheduler)
489
+ end
490
+
491
+ def cleanup_scheduler_state
492
+ state = Thread.current.thread_variable_get(:curb_scheduler_state)
493
+ state[:multi].close if state && state[:multi]
494
+ Thread.current.thread_variable_set(:curb_scheduler_state, nil)
495
+ end
496
+
497
+ def with_ephemeral_http_server
498
+ port_socket = TCPServer.new('127.0.0.1', 0)
499
+ port = port_socket.addr[1]
500
+ port_socket.close
501
+
502
+ hits = Hash.new(0)
503
+ server = WEBrick::HTTPServer.new(:Port => port, :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
504
+ server.mount_proc('/fast') do |_req, res|
505
+ hits[:fast] += 1
506
+ res['Content-Type'] = 'text/plain'
507
+ res.body = 'fast'
508
+ end
509
+ server.mount_proc('/slow') do |_req, res|
510
+ hits[:slow] += 1
511
+ res['Content-Type'] = 'text/plain'
512
+ sleep 0.2
513
+ res.body = 'slow'
514
+ end
515
+
516
+ server_thread = Thread.new(server) { |srv| srv.start }
517
+ wait_for_server_ready(port, thread: server_thread)
518
+
519
+ yield port, hits
520
+ ensure
521
+ server.shutdown if defined?(server) && server
522
+ server_thread.join(server_startup_timeout) if defined?(server_thread) && server_thread
523
+ server_thread.kill if defined?(server_thread) && server_thread&.alive?
524
+ end
190
525
  end
@@ -3,8 +3,9 @@
3
3
  require File.expand_path('helper', __dir__)
4
4
 
5
5
  class TestGcCompact < Test::Unit::TestCase
6
- ITERATIONS = (ENV['CURB_GC_COMPACT_ITERATIONS'] || 5).to_i
6
+ ITERATIONS = (ENV['CURB_GC_COMPACT_ITERATIONS'] || 50).to_i
7
7
  EASY_PER_MULTI = 3
8
+ GC_CRASH_REGRESSION_ITERATIONS = (ENV['CURB_GC_CRASH_REGRESSION_ITERATIONS'] || [ITERATIONS, 10].min).to_i
8
9
 
9
10
  def setup
10
11
  omit('GC.compact unavailable on this Ruby') unless defined?(GC.compact)
@@ -12,38 +13,153 @@ class TestGcCompact < Test::Unit::TestCase
12
13
 
13
14
  def test_multi_perform_with_gc_compact
14
15
  ITERATIONS.times do
15
- multi = Curl::Multi.new
16
- add_easy_handles(multi)
17
-
18
- compact
19
- assert_nothing_raised { multi.perform }
20
- compact
16
+ run_multi_perform_compact_iteration
17
+ collect_after_iteration
21
18
  end
22
19
  end
23
20
 
24
21
  def test_gc_compact_during_multi_cleanup
25
- ITERATIONS.times do
26
- multi = Curl::Multi.new
27
- add_easy_handles(multi)
22
+ omit('GC cleanup isolation requires fork') if NO_FORK || WINDOWS
28
23
 
29
- compact
30
- multi = nil
31
- compact
24
+ pid = fork do
25
+ begin
26
+ ITERATIONS.times do
27
+ run_multi_cleanup_compact_iteration
28
+ collect_after_iteration
29
+ end
30
+ exit! 0
31
+ rescue StandardError => e
32
+ warn("#{e.class}: #{e.message}")
33
+ warn(e.backtrace.join("\n")) if e.backtrace
34
+ exit! 1
35
+ end
32
36
  end
37
+
38
+ _child_pid, status = Process.wait2(pid)
39
+ assert_predicate status, :success?
33
40
  end
34
41
 
35
42
  def test_gc_compact_after_detach
43
+ run_multi_detach_compact_iteration
44
+ collect_after_iteration
45
+ end
46
+
47
+ def test_gc_compact_after_failed_implicit_multi_cleanup
48
+ GC_CRASH_REGRESSION_ITERATIONS.times do
49
+ run_failed_implicit_multi_cleanup_iteration(compact: true, cleanup_with_objectspace: false)
50
+ collect_after_iteration
51
+ end
52
+ end
53
+
54
+ def test_gc_cleanup_after_failed_implicit_multi_callback
55
+ omit('GC cleanup isolation requires fork') if NO_FORK || WINDOWS
56
+
57
+ assert_child_process_success do
58
+ GC_CRASH_REGRESSION_ITERATIONS.times do
59
+ run_failed_implicit_multi_cleanup_iteration(compact: true, cleanup_with_objectspace: true)
60
+ end
61
+ end
62
+ end
63
+
64
+ def test_gc_cleanup_after_invalid_multi_assignment_rejection
65
+ omit('GC cleanup isolation requires fork') if NO_FORK || WINDOWS
66
+
67
+ assert_child_process_success do
68
+ GC_CRASH_REGRESSION_ITERATIONS.times do
69
+ easy = Curl::Easy.new($TEST_URL)
70
+
71
+ begin
72
+ easy.multi = Object.new
73
+ raise 'expected TypeError from Curl::Easy#multi='
74
+ rescue TypeError => error
75
+ raise "unexpected error message: #{error.message}" unless /Curl::Multi/.match?(error.message)
76
+ end
77
+
78
+ easy = nil
79
+ full_gc(compact: true)
80
+ end
81
+ end
82
+ end
83
+
84
+ def test_gc_compact_easy
85
+ iteration = 0
86
+ responses = []
87
+ while iteration < ITERATIONS
88
+ res = Curl.get($TEST_URL) do |easy|
89
+ easy.timeout = 5
90
+ easy.on_complete { |_e| }
91
+ easy.on_failure { |_e, _code| }
92
+ end
93
+ iteration += 1
94
+ responses << res.body
95
+ compact
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def run_multi_perform_compact_iteration
102
+ multi = Curl::Multi.new
103
+ handles = add_easy_handles(multi)
104
+
105
+ compact
106
+ multi.perform { compact }
107
+ compact
108
+ ensure
109
+ handles&.each do |easy|
110
+ begin
111
+ easy.close
112
+ rescue StandardError
113
+ nil
114
+ end
115
+ end
116
+ multi&.close
117
+ end
118
+
119
+ def run_multi_cleanup_compact_iteration
120
+ multi = Curl::Multi.new
121
+ add_easy_handles(multi)
122
+
123
+ compact
124
+ multi = nil
125
+ compact
126
+ end
127
+
128
+ def run_multi_detach_compact_iteration
36
129
  multi = Curl::Multi.new
37
130
  handles = add_easy_handles(multi)
38
131
 
39
132
  compact
40
- assert_nothing_raised { multi.perform }
133
+ multi.perform { compact }
41
134
 
42
- handles.each { |easy| multi.remove(easy) }
135
+ handles.each { |easy| multi.remove(easy); compact }
43
136
  compact
137
+ ensure
138
+ multi&.close
44
139
  end
45
140
 
46
- private
141
+ def run_failed_implicit_multi_cleanup_iteration(compact:, cleanup_with_objectspace:)
142
+ easy = Curl::Easy.new($TEST_URL)
143
+ easy.on_complete { raise 'complete blew up' }
144
+
145
+ begin
146
+ easy.perform
147
+ raise 'expected callback abort'
148
+ rescue Curl::Err::AbortedByCallbackError => error
149
+ raise "unexpected callback message: #{error.message}" unless error.message == 'complete blew up'
150
+ end
151
+
152
+ raise 'expected implicit cleanup to clear easy.multi' unless easy.multi.nil?
153
+ ensure
154
+ easy = nil
155
+ if cleanup_with_objectspace
156
+ run_teardown_multi_cleanup
157
+ flush_deferred_multi_closes_all
158
+ else
159
+ flush_deferred_multi_closes_all
160
+ end
161
+ full_gc(compact: compact)
162
+ end
47
163
 
48
164
  def add_easy_handles(multi)
49
165
  Array.new(EASY_PER_MULTI) do
@@ -58,4 +174,50 @@ class TestGcCompact < Test::Unit::TestCase
58
174
  def compact
59
175
  GC.compact
60
176
  end
177
+
178
+ def full_gc(compact: false)
179
+ GC.start(full_mark: true, immediate_sweep: true)
180
+ GC.compact if compact && GC.respond_to?(:compact)
181
+ GC.start(full_mark: true, immediate_sweep: true)
182
+ end
183
+
184
+ def flush_deferred_multi_closes_all
185
+ return unless Curl::Easy.respond_to?(:flush_deferred_multi_closes)
186
+
187
+ Curl::Easy.flush_deferred_multi_closes(all_threads: true)
188
+ end
189
+
190
+ def run_teardown_multi_cleanup
191
+ ObjectSpace.each_object(Curl::Multi) do |multi|
192
+ begin
193
+ next if multi.instance_variable_defined?(:@deferred_close) && multi.instance_variable_get(:@deferred_close)
194
+
195
+ multi.instance_variable_set(:@requests, {})
196
+ multi._close
197
+ rescue StandardError
198
+ nil
199
+ end
200
+ end
201
+ end
202
+
203
+ def assert_child_process_success
204
+ pid = fork do
205
+ begin
206
+ yield
207
+ exit! 0
208
+ rescue Exception => e
209
+ warn("#{e.class}: #{e.message}")
210
+ warn(e.backtrace.join("\n")) if e.backtrace
211
+ exit! 1
212
+ end
213
+ end
214
+
215
+ _child_pid, status = Process.wait2(pid)
216
+ assert_predicate status, :success?
217
+ end
218
+
219
+ def collect_after_iteration
220
+ ObjectSpace.garbage_collect
221
+ ObjectSpace.garbage_collect
222
+ end
61
223
  end