curb 1.3.6 → 1.4.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,105 @@
1
+ # Reproduces the FIBER_TODO.md [Critical] finding: a Fiber scheduler that
2
+ # implements io_wait/kernel_sleep/block/unblock but NOT the optional io_select
3
+ # hook. rb_fiber_scheduler_io_select returns Qundef (without waiting) for such
4
+ # schedulers; if the multi-fd wait path of Curl::Multi#_socket_perform treats
5
+ # that as a completed wait, the drive loop spins at 100% CPU with no interrupt
6
+ # checkpoint and this script never exits — it cannot even service SIGTERM.
7
+ #
8
+ # Run by tc_fiber_scheduler.rb in a subprocess guarded by a SIGKILL fuse.
9
+ # Usage: ruby io_select_less_scheduler_probe.rb <port>
10
+ # <port> must serve HTTP GET /test (see BugTestServerSetupTeardown).
11
+
12
+ require 'fiber'
13
+ require 'curb'
14
+
15
+ port = Integer(ARGV[0])
16
+ url = "http://127.0.0.1:#{port}/test"
17
+
18
+ class NoIoSelectScheduler
19
+ def fiber(&block)
20
+ Fiber.new(blocking: false, &block)
21
+ end
22
+
23
+ def io_wait(io, events, timeout = nil)
24
+ readers = (events & IO::READABLE) != 0 ? [io] : nil
25
+ writers = (events & IO::WRITABLE) != 0 ? [io] : nil
26
+ deadline = monotonic_now + timeout if timeout
27
+
28
+ loop do
29
+ readable, writable = IO.select(readers, writers, nil, 0)
30
+ ready = 0
31
+ ready |= IO::READABLE if readable && !readable.empty?
32
+ ready |= IO::WRITABLE if writable && !writable.empty?
33
+ return ready unless ready.zero?
34
+ return false if deadline && monotonic_now >= deadline
35
+
36
+ Fiber.yield
37
+ end
38
+ end
39
+
40
+ def kernel_sleep(duration = nil)
41
+ deadline = monotonic_now + (duration || 0)
42
+ Fiber.yield while monotonic_now < deadline
43
+ duration
44
+ end
45
+
46
+ def block(_blocker, timeout = nil)
47
+ kernel_sleep(timeout)
48
+ false
49
+ end
50
+
51
+ def unblock(*); end
52
+
53
+ def close; end
54
+
55
+ def fiber_interrupt(*); end
56
+
57
+ private
58
+
59
+ def monotonic_now
60
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
+ end
62
+ end
63
+
64
+ results = []
65
+ ticks = 0
66
+ done = false
67
+
68
+ Fiber.set_scheduler(NoIoSelectScheduler.new)
69
+ fiber = Fiber.schedule do
70
+ multi = Curl::Multi.new
71
+ begin
72
+ # Two easies so the drive loop tracks more than one socket and takes the
73
+ # multi-fd (io_select) wait branch.
74
+ 2.times do
75
+ easy = Curl::Easy.new(url)
76
+ easy.on_complete { results << easy.response_code }
77
+ multi.add(easy)
78
+ end
79
+ multi.send(:_socket_perform)
80
+ ensure
81
+ multi.close
82
+ done = true
83
+ end
84
+ end
85
+ ticker = Fiber.schedule do
86
+ until done
87
+ sleep 0.005
88
+ ticks += 1 unless done
89
+ end
90
+ end
91
+
92
+ while fiber.alive? || ticker.alive?
93
+ fiber.resume if fiber.alive?
94
+ ticker.resume if ticker.alive?
95
+ sleep 0.001
96
+ end
97
+ Fiber.set_scheduler(nil)
98
+
99
+ if results == [200, 200]
100
+ puts "OK ticks=#{ticks}"
101
+ exit 0
102
+ else
103
+ puts "FAILED results=#{results.inspect}"
104
+ exit 1
105
+ end
@@ -1,8 +1,14 @@
1
1
  require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
- class FooNoToS
2
+ class FooNoToS
3
3
  undef to_s
4
4
  end
5
5
 
6
+ class FooBadToS
7
+ def to_s
8
+ 42
9
+ end
10
+ end
11
+
6
12
  class TestCurbCurlEasy < Test::Unit::TestCase
7
13
  def test_global_reset
8
14
  Curl.get($TEST_URL)
@@ -46,6 +52,7 @@ class TestCurbCurlEasy < Test::Unit::TestCase
46
52
 
47
53
  http = Curl::Easy.new(url)
48
54
  http.dns_cache_timeout = 0
55
+ http.proxy_url = ""
49
56
  http.resolve = [mapping]
50
57
  http.get
51
58
  assert_match(/GET/, http.body)
@@ -559,7 +566,24 @@ class TestCurbCurlEasy < Test::Unit::TestCase
559
566
  assert_equal "one\000two three", c.unescape("one%00two%20three")
560
567
  end
561
568
  end
562
-
569
+
570
+ def test_escape_with_non_string_to_s_result_does_not_crash
571
+ c = Curl::Easy.new
572
+ result = nil
573
+ # #to_s returning a non-String (42, a Fixnum) makes rb_obj_as_string
574
+ # fall back to the default Object#to_s representation rather than "42" -
575
+ # the point of this test is that it doesn't crash, not the exact value.
576
+ assert_nothing_raised { result = c.escape(FooBadToS.new) }
577
+ assert_kind_of String, result
578
+ end
579
+
580
+ def test_unescape_with_non_string_to_s_result_raises_type_error
581
+ c = Curl::Easy.new
582
+ # unescape expects an actual String (or something #to_str-coercible);
583
+ # it must raise cleanly rather than crash on a bad #to_s.
584
+ assert_raise(TypeError) { c.unescape(FooBadToS.new) }
585
+ end
586
+
563
587
  def test_headers
564
588
  c = Curl::Easy.new($TEST_URL)
565
589
 
@@ -744,7 +768,23 @@ class TestCurbCurlEasy < Test::Unit::TestCase
744
768
  c.http_auth_types = nil
745
769
  assert_nil c.http_auth_types
746
770
  end
747
-
771
+
772
+ def test_http_auth_types_with_non_string_to_s_result_does_not_crash
773
+ c = Curl::Easy.new
774
+ assert_nothing_raised { c.http_auth_types = FooBadToS.new }
775
+ assert_nothing_raised { c.http_auth_types = [FooBadToS.new, :basic] }
776
+ assert_equal Curl::CURLAUTH_BASIC, c.http_auth_types
777
+ end
778
+
779
+ def test_http_auth_types_array_of_symbols
780
+ c = Curl::Easy.new
781
+ c.http_auth_types = [:basic, :digest]
782
+ assert_equal Curl::CURLAUTH_BASIC | Curl::CURLAUTH_DIGEST, c.http_auth_types
783
+
784
+ c.send(:http_auth_types=, :basic, :ntlm)
785
+ assert_equal Curl::CURLAUTH_BASIC | Curl::CURLAUTH_NTLM, c.http_auth_types
786
+ end
787
+
748
788
  def test_proxy_auth_types_01
749
789
  c = Curl::Easy.new($TEST_URL)
750
790
 
@@ -756,7 +796,23 @@ class TestCurbCurlEasy < Test::Unit::TestCase
756
796
  c.proxy_auth_types = nil
757
797
  assert_nil c.proxy_auth_types
758
798
  end
759
-
799
+
800
+ def test_proxy_auth_types_symbols
801
+ c = Curl::Easy.new
802
+
803
+ c.proxy_auth_types = :basic
804
+ assert_equal Curl::CURLAUTH_BASIC, c.proxy_auth_types
805
+
806
+ c.proxy_auth_types = [:basic, :ntlm]
807
+ assert_equal Curl::CURLAUTH_BASIC | Curl::CURLAUTH_NTLM, c.proxy_auth_types
808
+
809
+ c.send(:proxy_auth_types=, :basic, :digest)
810
+ assert_equal Curl::CURLAUTH_BASIC | Curl::CURLAUTH_DIGEST, c.proxy_auth_types
811
+
812
+ assert_nothing_raised { c.proxy_auth_types = [FooBadToS.new, :basic] }
813
+ assert_equal Curl::CURLAUTH_BASIC, c.proxy_auth_types
814
+ end
815
+
760
816
  def test_max_redirects_01
761
817
  c = Curl::Easy.new($TEST_URL)
762
818
 
@@ -46,14 +46,17 @@ class TestCurbCurlEasyCookielist < Test::Unit::TestCase
46
46
  expires = (Date.today + 2).to_datetime
47
47
  easy.url = "http://localhost:#{TestServlet.port}#{TestServlet.path}/set_cookies"
48
48
  easy.setopt(Curl::CURLOPT_COOKIELIST, "Set-Cookie: c1=v1; domain=localhost; expires=#{expires.httpdate};")
49
- easy.post_body = JSON.generate([{ name: 'c2', value: 'v2', domain: 'localhost', expires: expires.httpdate, path: '/' }])
49
+ # c2 has no Domain attribute so it is host-only on every libcurl version.
50
+ # With domain=localhost, curl >= 8.22 built with libpsl makes it host-only
51
+ # (localhost is a public suffix) while older curl tail-matches (#483).
52
+ easy.post_body = JSON.generate([{ name: 'c2', value: 'v2', expires: expires.httpdate, path: '/' }])
50
53
  easy.perform
51
54
  easy.url = "http://localhost:#{TestServlet.port}#{TestServlet.path}/get_cookies"
52
55
  easy.post_body = nil
53
56
  easy.perform
54
57
 
55
58
  assert !easy.enable_cookies?
56
- assert_equal [".localhost\tTRUE\t/\tFALSE\t#{expires.to_time.to_i}\tc1\tv1", ".localhost\tTRUE\t/\tFALSE\t#{expires.to_time.to_i}\tc2\tv2"], easy.cookielist
59
+ assert_equal [".localhost\tTRUE\t/\tFALSE\t#{expires.to_time.to_i}\tc1\tv1", "localhost\tFALSE\t/\tFALSE\t#{expires.to_time.to_i}\tc2\tv2"], easy.cookielist
57
60
  assert_equal 'c2=v2; c1=v1', easy.body_str
58
61
  end
59
62
 
@@ -245,14 +248,15 @@ class TestCurbCurlEasyCookielist < Test::Unit::TestCase
245
248
  expires = (Date.today + 2).to_datetime
246
249
  easy.url = "http://localhost:#{TestServlet.port}#{TestServlet.path}/set_cookies"
247
250
  easy.setopt(Curl::CURLOPT_COOKIELIST, command)
248
- easy.post_body = JSON.generate([{ name: 'c2', value: 'v2', domain: 'localhost', expires: expires.httpdate, path: '/' }])
251
+ # host-only c2, see test_setopt_cookielist_enables_cookie_engine (#483)
252
+ easy.post_body = JSON.generate([{ name: 'c2', value: 'v2', expires: expires.httpdate, path: '/' }])
249
253
  easy.perform
250
254
  easy.url = "http://localhost:#{TestServlet.port}#{TestServlet.path}/get_cookies"
251
255
  easy.post_body = nil
252
256
  easy.perform
253
257
 
254
258
  assert !easy.enable_cookies?
255
- assert_equal [".localhost\tTRUE\t/\tFALSE\t#{expires.to_time.to_i}\tc2\tv2"], easy.cookielist
259
+ assert_equal ["localhost\tFALSE\t/\tFALSE\t#{expires.to_time.to_i}\tc2\tv2"], easy.cookielist
256
260
  assert_equal 'c2=v2', easy.body_str
257
261
  end
258
262
  end
@@ -22,6 +22,7 @@ class TestCurbCurlEasyResolve < Test::Unit::TestCase
22
22
  mapping = "#{host}:#{TestServlet.port}:127.0.0.1"
23
23
 
24
24
  @easy.url = "http://#{host}:#{TestServlet.port}#{TestServlet.path}"
25
+ @easy.proxy_url = ""
25
26
  @easy.dns_cache_timeout = 0
26
27
  @easy.set(:resolve, [mapping])
27
28
 
@@ -39,6 +40,7 @@ class TestCurbCurlEasyResolve < Test::Unit::TestCase
39
40
  entry.define_singleton_method(:to_s) { mapping }
40
41
 
41
42
  @easy.url = "http://#{host}:#{TestServlet.port}#{TestServlet.path}"
43
+ @easy.proxy_url = ""
42
44
  @easy.dns_cache_timeout = 0
43
45
  @easy.resolve = [entry]
44
46
 
@@ -586,6 +586,35 @@ class TestCurbCurlMulti < Test::Unit::TestCase
586
586
  end
587
587
  end
588
588
 
589
+ # Only easies attached by the raising callback itself are withdrawn. Work
590
+ # queued by an earlier, successful callback in the same perform was already
591
+ # attached when the failing callback started, so it keeps running and the
592
+ # deferred exception is raised once it drains.
593
+ def test_multi_perform_keeps_work_added_by_earlier_callback_when_later_callback_raises
594
+ server = CurbTinyHTTPServer.new
595
+ multi = Curl::Multi.new
596
+ first = Curl::Easy.new(server.url('/fast'))
597
+ failing = Curl::Easy.new(server.url('/delay/0.15'))
598
+ kept = Curl::Easy.new(server.url('/delay/0.4'))
599
+
600
+ first.on_complete { multi.add(kept) }
601
+ failing.on_complete { raise "complete blew up" }
602
+
603
+ error = assert_raise(Curl::Err::AbortedByCallbackError) do
604
+ multi.add(first)
605
+ multi.add(failing)
606
+ multi.perform
607
+ end
608
+
609
+ assert_equal "complete blew up", error.message
610
+ assert_equal 0, kept.last_result
611
+ assert_equal "delayed", kept.body_str,
612
+ "work added by an earlier successful callback should finish, not be withdrawn with the failing callback's additions"
613
+ ensure
614
+ multi.close if multi
615
+ server.close if server
616
+ end
617
+
589
618
  def test_multi_perform_does_not_start_work_added_within_on_complete_after_on_body_exception
590
619
  with_queue_refill_test_server do |port, hits|
591
620
  multi = Curl::Multi.new
@@ -0,0 +1,223 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
+
3
+ # Coverage for the blocking (non-scheduler) Curl::Multi#perform wait loop:
4
+ # descriptors above FD_SETSIZE, long waits, and interrupting those waits.
5
+ class TestCurbCurlMultiPoll < Test::Unit::TestCase
6
+ FD_SETSIZE = 1024
7
+ HIGH_FD_TARGET = FD_SETSIZE + 76
8
+
9
+ class PollInterrupt < StandardError; end
10
+
11
+ def setup
12
+ super
13
+ @server = CurbTinyHTTPServer.new
14
+ @original_default_timeout = Curl::Multi.default_timeout
15
+ end
16
+
17
+ def teardown
18
+ Curl::Multi.default_timeout = @original_default_timeout
19
+ @server.close if @server
20
+ super
21
+ end
22
+
23
+ def monotonic
24
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ end
26
+
27
+ # Under valgrind these bounds can no longer tell the old fixed-sleep loop
28
+ # from ordinary overhead. Skip only the bounds; the requests and their
29
+ # assertions still run under memcheck.
30
+ def assert_faster_than(limit, elapsed, message)
31
+ return if curb_under_valgrind?
32
+ assert_operator elapsed, :<, limit, message
33
+ end
34
+
35
+ # Occupy low descriptor numbers so every socket libcurl opens afterwards is
36
+ # numbered above FD_SETSIZE and cannot be represented in an fd_set.
37
+ def with_fds_above_fd_setsize
38
+ omit('fd numbering differs on Windows') if WINDOWS
39
+ # Before 7.68 there is no curl_multi_poll and perform falls back to
40
+ # select(), which cannot represent descriptors above FD_SETSIZE.
41
+ omit('libcurl < 7.68 has no curl_multi_poll') if Gem::Version.new(Curl::CURL_VERSION[/\A\d+(\.\d+)*/]) < Gem::Version.new('7.68.0')
42
+
43
+ soft, hard = Process.getrlimit(:NOFILE)
44
+ needed = HIGH_FD_TARGET + 256
45
+ if soft < needed
46
+ omit("RLIMIT_NOFILE hard limit #{hard} is too low") if hard != Process::RLIM_INFINITY && hard < needed
47
+ Process.setrlimit(:NOFILE, needed, hard)
48
+ end
49
+
50
+ hog = []
51
+ hog << File.open(File::NULL) while hog.empty? || hog.last.fileno < HIGH_FD_TARGET
52
+
53
+ probe = Socket.new(:INET, :STREAM)
54
+ assert_operator probe.fileno, :>, FD_SETSIZE, "new sockets should be numbered above FD_SETSIZE"
55
+ probe.close
56
+
57
+ yield
58
+ ensure
59
+ hog.each(&:close) if hog
60
+ Process.setrlimit(:NOFILE, soft, hard) if soft && Process.getrlimit(:NOFILE)[0] != soft
61
+ end
62
+
63
+ # A request whose socket is above FD_SETSIZE used to be invisible to the
64
+ # curl_multi_fdset/select loop, which then fell back to fixed 100ms sleeps,
65
+ # so each request took at least 100ms. Ten sequential local requests would
66
+ # need a full second.
67
+ def test_easy_perform_with_socket_fds_above_fd_setsize
68
+ with_fds_above_fd_setsize do
69
+ started = monotonic
70
+ 10.times do
71
+ assert_equal "fast", Curl.get(@server.url('/fast')).body_str
72
+ end
73
+ elapsed = monotonic - started
74
+
75
+ assert_faster_than 0.6, elapsed, "10 local requests took #{elapsed.round(3)}s with high-numbered sockets"
76
+ end
77
+ end
78
+
79
+ # Batches of concurrent requests share each 100ms sleep of the old loop, so
80
+ # run several batches to make that fixed floor (>= 1s total) visible.
81
+ def test_multi_concurrent_requests_with_socket_fds_above_fd_setsize
82
+ with_fds_above_fd_setsize do
83
+ started = monotonic
84
+ 5.times do |batch|
85
+ multi = Curl::Multi.new
86
+ easies = 20.times.map do |i|
87
+ easy = Curl::Easy.new(@server.url("/fast?#{batch}-#{i}"))
88
+ multi.add(easy)
89
+ easy
90
+ end
91
+
92
+ multi.perform
93
+
94
+ easies.each do |easy|
95
+ assert_equal 200, easy.response_code
96
+ assert_equal "fast", easy.body_str
97
+ end
98
+ ensure
99
+ multi.close if multi
100
+ end
101
+ elapsed = monotonic - started
102
+
103
+ assert_faster_than 0.8, elapsed, "5 batches of 20 concurrent local requests took #{elapsed.round(3)}s with high-numbered sockets"
104
+ end
105
+ end
106
+
107
+ # With a large default_timeout, readiness on the transfer's socket must still
108
+ # wake the wait immediately rather than waiting out the timeout.
109
+ def test_long_default_timeout_still_wakes_on_socket_activity
110
+ Curl::Multi.default_timeout = 5_000
111
+
112
+ multi = Curl::Multi.new
113
+ fast = Curl::Easy.new(@server.url('/fast'))
114
+ delayed = Curl::Easy.new(@server.url('/delay/0.3'))
115
+ multi.add(fast)
116
+ multi.add(delayed)
117
+
118
+ started = monotonic
119
+ multi.perform
120
+ elapsed = monotonic - started
121
+
122
+ assert_equal "fast", fast.body_str
123
+ assert_equal "delayed", delayed.body_str
124
+ assert_operator elapsed, :<, 2.0, "perform waited #{elapsed.round(3)}s; the 5s default_timeout should not delay completion"
125
+ ensure
126
+ multi.close if multi
127
+ end
128
+
129
+ # The block passed to perform is yielded each time the wait times out, so a
130
+ # small default_timeout keeps it running while a transfer is idle.
131
+ def test_perform_block_yields_while_waiting
132
+ Curl::Multi.default_timeout = 20
133
+
134
+ multi = Curl::Multi.new
135
+ easy = Curl::Easy.new(@server.url('/delay/0.4'))
136
+ multi.add(easy)
137
+
138
+ yields = 0
139
+ multi.perform { yields += 1 }
140
+
141
+ assert_equal "delayed", easy.body_str
142
+ assert_operator yields, :>=, 5, "perform block ran #{yields} times during a 400ms idle transfer"
143
+ ensure
144
+ multi.close if multi
145
+ end
146
+
147
+ def run_interruptible_hang
148
+ Curl::Multi.default_timeout = 30_000
149
+ errors = Queue.new
150
+
151
+ worker = Thread.new do
152
+ Thread.current.report_on_exception = false
153
+ begin
154
+ Curl.get(@server.url('/hang'))
155
+ errors << nil
156
+ rescue Exception => e
157
+ errors << e
158
+ raise
159
+ end
160
+ end
161
+
162
+ assert_equal '/hang', curb_pop_within(@server.requests, 5), "server never received the request"
163
+ # Give the worker a moment to settle into the blocking wait.
164
+ sleep 0.1
165
+
166
+ started = monotonic
167
+ yield worker
168
+ joined = worker.join(3) rescue worker
169
+ elapsed = monotonic - started
170
+
171
+ assert_not_nil joined, "worker was still blocked 3s after being interrupted"
172
+ assert_operator elapsed, :<, 2.0, "interrupt took #{elapsed.round(3)}s to stop the wait"
173
+ errors.empty? ? nil : errors.pop
174
+ ensure
175
+ if worker&.alive?
176
+ worker.kill
177
+ worker.join(1)
178
+ end
179
+ end
180
+
181
+ def test_thread_raise_interrupts_long_wait
182
+ error = run_interruptible_hang { |worker| worker.raise(PollInterrupt, "stop waiting") }
183
+ assert_kind_of PollInterrupt, error
184
+ end
185
+
186
+ def test_thread_kill_interrupts_long_wait
187
+ run_interruptible_hang { |worker| worker.kill }
188
+ end
189
+
190
+ # A trapped signal must run its handler promptly during a long wait, and the
191
+ # wait must then resume so the transfer still completes. The signal can
192
+ # interrupt the poll with EINTR directly, so this guards against treating
193
+ # that as a failed wait rather than exercising the unblocking function
194
+ # (the Thread#raise/#kill tests cover that).
195
+ def test_signal_trap_runs_during_long_wait_and_transfer_completes
196
+ omit('POSIX signals are not available on Windows') if WINDOWS
197
+ Curl::Multi.default_timeout = 30_000
198
+
199
+ handled_at = nil
200
+ previous = trap('USR2') { handled_at = monotonic }
201
+ trapped = true
202
+ sent_at = nil
203
+
204
+ signaller = Thread.new do
205
+ curb_pop_within(@server.requests, 5)
206
+ sleep 0.1
207
+ sent_at = monotonic
208
+ Process.kill('USR2', Process.pid)
209
+ sleep 0.3
210
+ @server.release_hung(1)
211
+ end
212
+
213
+ easy = Curl.get(@server.url('/hang'))
214
+ signaller.join(5)
215
+
216
+ assert_equal "released", easy.body_str
217
+ assert_not_nil handled_at, "USR2 handler never ran"
218
+ assert_operator handled_at - sent_at, :<, 0.25, "signal handler was delayed #{(handled_at - sent_at).round(3)}s by the wait"
219
+ ensure
220
+ trap('USR2', previous || 'DEFAULT') if trapped
221
+ signaller&.kill
222
+ end
223
+ end