curb 1.3.6 → 1.3.7

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.
@@ -46,11 +46,12 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
46
46
  end
47
47
 
48
48
  def kernel_sleep(duration = nil)
49
- sleep(duration || 0)
49
+ # A bare sleep here would re-enter this hook through the scheduler.
50
+ blocking_io { sleep(duration || 0) }
50
51
  end
51
52
 
52
53
  def block(_blocker, timeout = nil)
53
- sleep(timeout || 0)
54
+ blocking_io { sleep(timeout || 0) }
54
55
  false
55
56
  end
56
57
 
@@ -69,7 +70,7 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
69
70
  if Fiber.respond_to?(:blocking)
70
71
  Fiber.blocking(&block)
71
72
  else
72
- block.call
73
+ Fiber.new(blocking: true, &block).resume
73
74
  end
74
75
  end
75
76
  end
@@ -215,7 +216,7 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
215
216
  c = Curl::Easy.new(url)
216
217
  c.on_complete { result = c.code }
217
218
  m.add(c)
218
- unless m.respond_to?(:_socket_perform, true)
219
+ unless socket_perform_supported?
219
220
  omit('socket-action perform path is not available in this build')
220
221
  end
221
222
  m.send(:_socket_perform)
@@ -231,6 +232,249 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
231
232
  end
232
233
  end
233
234
 
235
+ # Multi#perform must not fall back to the thread-blocking legacy fdset loop
236
+ # when the calling fiber runs under a scheduler: the waits have to go
237
+ # through the scheduler's io_wait/io_select hooks so sibling fibers run.
238
+ def test_multi_perform_waits_via_scheduler_hooks
239
+ omit('Skipping custom scheduler test on Windows') if WINDOWS
240
+ omit('Fiber scheduler API unavailable on this Ruby') unless fiber_scheduler_supported?
241
+
242
+ url = "http://127.0.0.1:#{@port}/test"
243
+ scheduler = RecordingScheduler.new
244
+ result = nil
245
+
246
+ with_scheduler(scheduler) do
247
+ m = Curl::Multi.new
248
+ unless socket_perform_supported?
249
+ omit('socket-action perform path is not available in this build')
250
+ end
251
+ c = Curl::Easy.new(url)
252
+ c.on_complete { result = c.code }
253
+ m.add(c)
254
+ m.perform
255
+ ensure
256
+ m.close if m
257
+ end
258
+
259
+ assert_equal 200, result
260
+ assert_operator scheduler.io_wait_events.length + scheduler.io_select_calls, :>=, 1,
261
+ 'Multi#perform under a fiber scheduler should wait through the scheduler hooks'
262
+ end
263
+
264
+ def test_multi_perform_propagates_io_wrapper_errors
265
+ omit('Skipping custom scheduler test on Windows') if WINDOWS
266
+ omit('Fiber scheduler API unavailable on this Ruby') unless fiber_scheduler_supported?
267
+ omit('socket-action perform path is not available in this build') unless socket_perform_supported?
268
+
269
+ url = "http://127.0.0.1:#{@port}/test"
270
+ scheduler = RecordingScheduler.new
271
+ wrapper_error = Class.new(StandardError)
272
+ original_for_fd = IO.method(:for_fd)
273
+ redefine_io_for_fd do |*_args|
274
+ raise wrapper_error, 'IO wrapper failed'
275
+ end
276
+
277
+ error = assert_raise(wrapper_error) do
278
+ with_scheduler(scheduler) do
279
+ m = Curl::Multi.new
280
+ m.add(Curl::Easy.new(url))
281
+ m.perform
282
+ ensure
283
+ m.close if m
284
+ end
285
+ end
286
+
287
+ assert_equal 'IO wrapper failed', error.message
288
+ ensure
289
+ redefine_io_for_fd(original_for_fd) if original_for_fd
290
+ end
291
+
292
+ # Probe-style regression tests: all previous concurrency assertions routed N
293
+ # requests through one multi handle, so libcurl provided the parallelism and
294
+ # they passed even when the wait loop blocked the whole thread. Here a
295
+ # sibling fiber ticks every 10ms while a single request is in flight; a
296
+ # blocking wait loop caps the ticks at roughly one per 100ms select chunk,
297
+ # while a scheduler-friendly loop lets most of the ~MIN_S/0.01 ticks happen.
298
+ def test_sibling_fibers_progress_during_multi_perform
299
+ return if skip_no_async
300
+ return if skip_no_socket_perform
301
+
302
+ url = "http://127.0.0.1:#{@port}/test"
303
+ result = nil
304
+
305
+ ticks = probe_sibling_ticks do
306
+ m = Curl::Multi.new
307
+ begin
308
+ c = Curl::Easy.new(url)
309
+ c.on_complete { result = c.code }
310
+ m.add(c)
311
+ m.perform
312
+ ensure
313
+ m.close
314
+ end
315
+ end
316
+
317
+ assert_equal 200, result
318
+ assert_operator ticks, :>=, SIBLING_TICKS_MIN,
319
+ "sibling fiber starved during Multi#perform: #{ticks} ticks in a #{MIN_S}s transfer (~#{SIBLING_TICKS_POSSIBLE} possible)"
320
+ end
321
+
322
+ def test_sibling_fibers_progress_during_easy_perform
323
+ return if skip_no_async
324
+ return if skip_no_socket_perform
325
+
326
+ url = "http://127.0.0.1:#{@port}/test"
327
+ result = nil
328
+
329
+ ticks = probe_sibling_ticks do
330
+ c = Curl::Easy.new(url)
331
+ c.perform
332
+ result = c.code
333
+ end
334
+
335
+ assert_equal 200, result
336
+ assert_operator ticks, :>=, SIBLING_TICKS_MIN,
337
+ "sibling fiber starved during Easy#perform: #{ticks} ticks in a #{MIN_S}s transfer (~#{SIBLING_TICKS_POSSIBLE} possible)"
338
+ end
339
+
340
+ def test_sibling_fibers_progress_during_multi_perform_with_multiple_sockets
341
+ return if skip_no_async
342
+ return if skip_no_socket_perform
343
+
344
+ url = "http://127.0.0.1:#{@port}/test"
345
+ results = []
346
+
347
+ ticks = probe_sibling_ticks do
348
+ m = Curl::Multi.new
349
+ begin
350
+ 2.times do
351
+ c = Curl::Easy.new(url)
352
+ c.on_complete { results << c.code }
353
+ m.add(c)
354
+ end
355
+ m.perform
356
+ ensure
357
+ m.close
358
+ end
359
+ end
360
+
361
+ assert_equal [200, 200], results.sort
362
+ assert_operator ticks, :>=, SIBLING_TICKS_MIN,
363
+ "sibling fiber starved during multi-fd Multi#perform: #{ticks} ticks in a #{MIN_S}s transfer (~#{SIBLING_TICKS_POSSIBLE} possible)"
364
+ end
365
+
366
+ def test_scheduler_timeout_interrupts_single_socket_multi_perform
367
+ return if skip_no_async
368
+ return if skip_no_socket_perform
369
+
370
+ assert_scheduler_timeout_interrupts_multi_perform(1)
371
+ end
372
+
373
+ def test_scheduler_timeout_interrupts_multi_socket_multi_perform
374
+ return if skip_no_async
375
+ return if skip_no_socket_perform
376
+
377
+ assert_scheduler_timeout_interrupts_multi_perform(2)
378
+ end
379
+
380
+ def test_multi_perform_runs_work_added_from_final_idle_yield_under_scheduler
381
+ return if skip_no_async
382
+ return if skip_no_socket_perform
383
+
384
+ url = "http://127.0.0.1:#{@port}/test"
385
+ completions = []
386
+ empty_yields = 0
387
+ queued_added = false
388
+ previous_autoclose = Curl::Multi.autoclose
389
+ Curl::Multi.autoclose = true
390
+
391
+ begin
392
+ async_run do
393
+ m = Curl::Multi.new
394
+ begin
395
+ first = Curl::Easy.new(url)
396
+ first.on_complete { completions << :first }
397
+ m.add(first)
398
+
399
+ m.perform do |performing_multi|
400
+ empty_yields += 1 if performing_multi.requests.empty?
401
+ if !queued_added && empty_yields >= 2
402
+ queued_added = true
403
+ queued = Curl::Easy.new(url)
404
+ queued.on_complete { completions << :queued }
405
+ performing_multi.add(queued)
406
+ end
407
+ end
408
+ ensure
409
+ m.close
410
+ end
411
+ end
412
+
413
+ assert queued_added, 'test should add queued work from the final idle yield'
414
+ assert_equal [:first, :queued], completions
415
+ ensure
416
+ Curl::Multi.autoclose = previous_autoclose
417
+ end
418
+ end
419
+
420
+ # A scheduler that implements io_wait but not the optional io_select hook
421
+ # must neither busy-loop nor fall back to a thread-blocking wait. The broken
422
+ # loop spins without an interrupt checkpoint, so the probe runs in a
423
+ # subprocess guarded by a parent-side SIGKILL fuse.
424
+ def test_socket_perform_progresses_when_scheduler_lacks_io_select
425
+ omit('Skipping custom scheduler test on Windows') if WINDOWS
426
+ omit('Fiber scheduler API unavailable on this Ruby') unless fiber_scheduler_supported?
427
+ unless socket_perform_supported?
428
+ omit('socket-action perform path is not available in this build')
429
+ end
430
+
431
+ script = File.join(File.dirname(__FILE__), 'io_select_less_scheduler_probe.rb')
432
+ out_r, out_w = IO.pipe
433
+ pid = Process.spawn(RbConfig.ruby, '-I', $LIBDIR, '-I', $EXTDIR, script, @port.to_s,
434
+ :out => out_w, :err => out_w)
435
+ out_w.close
436
+
437
+ fuse_seconds = 30
438
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + fuse_seconds
439
+ status = nil
440
+ loop do
441
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
442
+ break if status
443
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
444
+ begin
445
+ Process.kill('KILL', pid)
446
+ rescue Errno::ESRCH
447
+ end
448
+ begin
449
+ Process.waitpid(pid)
450
+ rescue Errno::ECHILD
451
+ end
452
+ flunk "scheduler without io_select busy-looped _socket_perform (no exit within #{fuse_seconds}s; killed)"
453
+ end
454
+ sleep 0.05
455
+ end
456
+
457
+ output = out_r.read
458
+ assert status.success?, "io_select-less scheduler subprocess failed: #{output}"
459
+ match = /^OK ticks=(\d+)$/.match(output)
460
+ assert_not_nil match, "unexpected io_select-less scheduler output: #{output}"
461
+ assert_operator match[1].to_i, :>=, 2,
462
+ "scheduler without io_select did not make sibling-fiber progress: #{output}"
463
+ ensure
464
+ if pid && !status
465
+ begin
466
+ Process.kill('KILL', pid)
467
+ rescue Errno::ESRCH
468
+ end
469
+ begin
470
+ Process.waitpid(pid)
471
+ rescue Errno::ECHILD
472
+ end
473
+ end
474
+ out_r.close if out_r && !out_r.closed?
475
+ out_w.close if out_w && !out_w.closed?
476
+ end
477
+
234
478
  def test_multi_reuse_after_scheduler_perform
235
479
  unless HAS_ASYNC
236
480
  warn 'Skipping fiber scheduler test (Async gem not available)'
@@ -508,7 +752,79 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
508
752
  cleanup_scheduler_state
509
753
  end
510
754
 
755
+ # Sibling-fiber probe bounds: ~MIN_S/0.01 ticks are possible while the
756
+ # request is in flight. A thread-blocking wait loop yields only one tick per
757
+ # 100ms select chunk (2-3 for MIN_S=0.25); require a comfortably higher
758
+ # count while leaving slack for slow CI hosts.
759
+ SIBLING_TICKS_POSSIBLE = (MIN_S / 0.01).to_i
760
+ SIBLING_TICKS_MIN = 8
761
+
511
762
  private
763
+ def redefine_io_for_fd(callable = nil, &block)
764
+ previous_verbose = $VERBOSE
765
+ $VERBOSE = nil
766
+ if callable
767
+ IO.define_singleton_method(:for_fd, callable)
768
+ else
769
+ IO.define_singleton_method(:for_fd, &block)
770
+ end
771
+ ensure
772
+ $VERBOSE = previous_verbose
773
+ end
774
+
775
+ def assert_scheduler_timeout_interrupts_multi_perform(easy_count)
776
+ url = "http://127.0.0.1:#{@port}/test"
777
+ timeout_error = nil
778
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
779
+
780
+ async_run do |task|
781
+ begin
782
+ task.with_timeout(0.05) do
783
+ m = Curl::Multi.new
784
+ begin
785
+ easy_count.times { m.add(Curl::Easy.new(url)) }
786
+ m.perform
787
+ ensure
788
+ m.close
789
+ end
790
+ end
791
+ rescue Async::TimeoutError => error
792
+ timeout_error = error
793
+ end
794
+ end
795
+
796
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
797
+ assert_kind_of Async::TimeoutError, timeout_error,
798
+ "scheduler timeout was swallowed for #{easy_count} easy handle(s)"
799
+ assert_operator elapsed, :<, MIN_S * 0.8,
800
+ "scheduler cancellation took #{elapsed}s for #{easy_count} easy handle(s)"
801
+ end
802
+
803
+ # Runs the block in one fiber while a sibling fiber ticks every 10ms;
804
+ # returns how many ticks the sibling managed before the block finished.
805
+ def probe_sibling_ticks
806
+ ticks = 0
807
+ done = false
808
+
809
+ async_run do |top|
810
+ ticker = top.async do
811
+ until done
812
+ sleep 0.01
813
+ ticks += 1 unless done
814
+ end
815
+ end
816
+
817
+ top.async do
818
+ yield
819
+ ensure
820
+ done = true
821
+ end.wait
822
+ ticker.wait
823
+ end
824
+
825
+ ticks
826
+ end
827
+
512
828
  def skip_no_async
513
829
  if WINDOWS
514
830
  warn 'Skipping fiber scheduler tests on Windows'
@@ -525,6 +841,18 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
525
841
  false
526
842
  end
527
843
 
844
+ def skip_no_socket_perform
845
+ unless socket_perform_supported?
846
+ warn 'Skipping fiber scheduler test (socket-action perform path unavailable)'
847
+ return true
848
+ end
849
+ false
850
+ end
851
+
852
+ def socket_perform_supported?
853
+ Curl::Multi.private_method_defined?(:_socket_perform)
854
+ end
855
+
528
856
  def async_run(&block)
529
857
  # Prefer newer Async.run to avoid deprecated scheduler.async path.
530
858
  if defined?(Async) && Async.respond_to?(:run)
@@ -1,6 +1,23 @@
1
1
  require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
2
 
3
3
  class TestCurbFtpOptions < Test::Unit::TestCase
4
+ SCALAR_AND_STRING_OPTIONS = {
5
+ :ftpport => "-",
6
+ :dirlistonly => true,
7
+ :append => true,
8
+ :ftp_use_eprt => false,
9
+ :ftp_use_epsv => false,
10
+ :ftp_use_pret => true,
11
+ :ftp_create_missing_dirs => true,
12
+ :ftp_response_timeout => 30,
13
+ :ftp_alternative_to_user => "anonymous",
14
+ :ftp_skip_pasv_ip => true,
15
+ :ftpsslauth => 0,
16
+ :ftp_ssl_ccc => 0,
17
+ :ftp_account => "account",
18
+ :ftp_filemethod => 0
19
+ }.freeze
20
+
4
21
  # Ensure FTP-related set(:option, ...) mappings are accepted and do not raise
5
22
  # a TypeError (they used to be unsupported in setopt dispatch).
6
23
  def test_can_set_ftp_listing_related_flags
@@ -15,6 +32,47 @@ class TestCurbFtpOptions < Test::Unit::TestCase
15
32
  end
16
33
  end
17
34
 
35
+ def test_can_set_detected_scalar_and_string_ftp_options
36
+ c = Curl::Easy.new('ftp://example.com/')
37
+
38
+ SCALAR_AND_STRING_OPTIONS.each do |name, value|
39
+ constant = "CURLOPT_#{name.to_s.upcase}"
40
+ next unless Curl.const_defined?(constant)
41
+
42
+ assert_nothing_raised("setting #{name}") { c.set(name, value) }
43
+ end
44
+ end
45
+
46
+ def test_issue_481_ftp_create_missing_dirs_accessor_and_set
47
+ omit('CURLOPT_FTP_CREATE_MISSING_DIRS is not available in this build') unless
48
+ Curl.const_defined?(:CURLOPT_FTP_CREATE_MISSING_DIRS)
49
+
50
+ c = Curl::Easy.new('ftp://example.com/')
51
+
52
+ assert_respond_to(c, :ftp_create_missing_dirs=)
53
+ assert_respond_to(c, :ftp_create_missing_dirs)
54
+ assert_nil(c.ftp_create_missing_dirs)
55
+
56
+ c.ftp_create_missing_dirs = true
57
+ assert_equal(true, c.ftp_create_missing_dirs)
58
+
59
+ assert_equal(1, c.set(:ftp_create_missing_dirs, 1))
60
+ assert_equal(1, c.ftp_create_missing_dirs)
61
+
62
+ retry_mode = if Curl.const_defined?(:CURLFTP_CREATE_DIR_RETRY)
63
+ Curl::CURLFTP_CREATE_DIR_RETRY
64
+ else
65
+ 2
66
+ end
67
+ c.set(:ftp_create_missing_dirs, retry_mode)
68
+ assert_equal(retry_mode, c.ftp_create_missing_dirs)
69
+ assert_equal(retry_mode, c.clone.ftp_create_missing_dirs)
70
+
71
+ settings = c.reset
72
+ assert_equal(retry_mode, settings[:ftp_create_missing_dirs])
73
+ assert_nil(c.ftp_create_missing_dirs)
74
+ end
75
+
18
76
  # Setting ftp_commands remains supported for control-connection commands.
19
77
  def test_can_assign_ftp_commands
20
78
  c = Curl::Easy.new('ftp://example.com/')
@@ -96,6 +96,59 @@ class TestGcCompact < Test::Unit::TestCase
96
96
  end
97
97
  end
98
98
 
99
+ # Regression: after compaction relocates a persistent Curl::Easy and its old
100
+ # heap slot is recycled by another T_DATA object, completion dispatch (which
101
+ # reads the rbce->self back-reference via CURLINFO_PRIVATE) must still
102
+ # resolve to the correct Curl::Easy. Without pinning in curl_easy_mark this
103
+ # raises "TypeError: wrong argument type Curl::Upload (expected Curl::Easy)"
104
+ # on the first round, or defers it onto the multi and raises it from a later
105
+ # request.
106
+ def test_completion_dispatch_survives_compact_and_slot_reuse
107
+ omit('needs GC.verify_compaction_references') unless GC.respond_to?(:verify_compaction_references)
108
+
109
+ # Heap-only reference: holding the easy in a local would pin it to the
110
+ # stack and mask the bug.
111
+ holder = { easy: Curl::Easy.new($TEST_URL) }
112
+ holder[:easy].timeout = 5
113
+ completed = nil
114
+ holder[:easy].on_complete { |e| completed = e }
115
+ holder[:easy].http(:GET)
116
+ assert_same holder[:easy], completed
117
+ expected_body = holder[:easy].body_str
118
+ refute_empty expected_body
119
+
120
+ ruby_version = RUBY_VERSION.split('.').first(2).map(&:to_i)
121
+ heap_expansion = if (ruby_version <=> [3, 2]) >= 0
122
+ { expand_heap: true }
123
+ else
124
+ { double_heap: true }
125
+ end
126
+
127
+ 3.times do
128
+ # Relocate every movable object, then recycle freed slots with other
129
+ # T_DATA instances so a stale back-reference cannot fail soft.
130
+ GC.verify_compaction_references(toward: :empty, **heap_expansion)
131
+ churn = Array.new(50_000) { Curl::Upload.new }
132
+
133
+ completed = nil
134
+ holder[:easy].http(:GET)
135
+
136
+ multi = holder[:easy].multi
137
+ if multi&.instance_variable_defined?(:@__curb_deferred_exception)
138
+ raise multi.instance_variable_get(:@__curb_deferred_exception)
139
+ end
140
+ assert_same holder[:easy], completed
141
+ assert_equal expected_body, holder[:easy].body_str
142
+ churn.clear
143
+ end
144
+ ensure
145
+ begin
146
+ holder[:easy]&.close
147
+ rescue StandardError
148
+ nil
149
+ end
150
+ end
151
+
99
152
  private
100
153
 
101
154
  def run_multi_perform_compact_iteration
@@ -107,4 +107,19 @@ class TestServerMethodsLockHandling < Test::Unit::TestCase
107
107
  assert_equal false, File.exist?(locked_file)
108
108
  assert_equal false, server_responding?(@__port)
109
109
  end
110
+
111
+ def test_server_setup_reuses_the_process_server
112
+ server_setup(@__port)
113
+ first_server = @server
114
+
115
+ other = Object.new
116
+ other.extend(TestServerMethods)
117
+ lock_path = locked_file
118
+ other.define_singleton_method(:locked_file) { lock_path }
119
+ other.server_setup(@__port)
120
+
121
+ assert_same first_server, other.instance_variable_get(:@server)
122
+ ensure
123
+ other.stop_test_server if defined?(other) && other
124
+ end
110
125
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: curb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.6
4
+ version: 1.3.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ross Bamford
8
8
  - Todd A. Fisher
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-17 00:00:00.000000000 Z
11
+ date: 2026-07-10 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Curb (probably CUrl-RuBy or something) provides Ruby-language bindings
14
14
  for the libcurl(3), a fully-featured client-side URL transfer library. cURL and
@@ -64,6 +64,7 @@ files:
64
64
  - tests/bug_require_last_or_segfault.rb
65
65
  - tests/bugtests.rb
66
66
  - tests/helper.rb
67
+ - tests/io_select_less_scheduler_probe.rb
67
68
  - tests/leak_trace.rb
68
69
  - tests/mem_check.rb
69
70
  - tests/require_last_or_segfault_script.rb
@@ -133,6 +134,7 @@ test_files:
133
134
  - tests/bug_require_last_or_segfault.rb
134
135
  - tests/bugtests.rb
135
136
  - tests/helper.rb
137
+ - tests/io_select_less_scheduler_probe.rb
136
138
  - tests/leak_trace.rb
137
139
  - tests/mem_check.rb
138
140
  - tests/require_last_or_segfault_script.rb