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.
- checksums.yaml +4 -4
- data/README.md +71 -14
- data/Rakefile +12 -1
- data/ext/curb.c +26 -4
- data/ext/curb.h +4 -4
- data/ext/curb_easy.c +178 -77
- data/ext/curb_easy.h +1 -0
- data/ext/curb_multi.c +639 -225
- data/ext/curb_multi.h +4 -0
- data/ext/curb_postfield.c +31 -31
- data/ext/extconf.rb +10 -0
- data/lib/curl/easy.rb +18 -9
- data/lib/curl.rb +49 -12
- data/tests/helper.rb +300 -4
- data/tests/io_select_less_scheduler_probe.rb +105 -0
- data/tests/tc_curl_easy.rb +60 -4
- data/tests/tc_curl_easy_cookielist.rb +8 -4
- data/tests/tc_curl_easy_resolve.rb +2 -0
- data/tests/tc_curl_multi.rb +29 -0
- data/tests/tc_curl_multi_poll.rb +223 -0
- data/tests/tc_curl_multi_socket_poller.rb +374 -0
- data/tests/tc_curl_native_coverage.rb +1 -0
- data/tests/tc_curl_postfield.rb +39 -0
- data/tests/tc_fiber_scheduler.rb +433 -6
- data/tests/tc_ftp_options.rb +58 -0
- data/tests/tc_gc_compact.rb +53 -0
- data/tests/tc_ractor.rb +94 -0
- data/tests/tc_test_server_methods.rb +15 -0
- metadata +10 -2
|
@@ -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
|
data/tests/tc_curl_easy.rb
CHANGED
|
@@ -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
|
-
|
|
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", "
|
|
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
|
-
|
|
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 ["
|
|
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
|
|
data/tests/tc_curl_multi.rb
CHANGED
|
@@ -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
|