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,374 @@
|
|
|
1
|
+
require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
|
|
2
|
+
|
|
3
|
+
begin
|
|
4
|
+
require 'async'
|
|
5
|
+
rescue LoadError
|
|
6
|
+
nil
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Coverage for the socket-action loop's kernel event queue (epoll/kqueue):
|
|
10
|
+
# under a fiber scheduler every libcurl socket is waited on through a single
|
|
11
|
+
# poller descriptor with io_wait, instead of the optional io_select hook.
|
|
12
|
+
class TestCurbCurlMultiSocketPoller < Test::Unit::TestCase
|
|
13
|
+
class PollerAbort < StandardError; end
|
|
14
|
+
|
|
15
|
+
class RecordingScheduler
|
|
16
|
+
attr_reader :io_wait_calls, :io_select_calls
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
@io_wait_calls = []
|
|
20
|
+
@io_select_calls = 0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fiber(&block)
|
|
24
|
+
Fiber.new(blocking: false, &block)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def io_wait(io, events, timeout = nil)
|
|
28
|
+
@io_wait_calls << [io.fileno, events]
|
|
29
|
+
readers = (events & IO::READABLE) != 0 ? [io] : nil
|
|
30
|
+
writers = (events & IO::WRITABLE) != 0 ? [io] : nil
|
|
31
|
+
readable, writable = blocking_io { IO.select(readers, writers, nil, timeout) }
|
|
32
|
+
|
|
33
|
+
ready = 0
|
|
34
|
+
ready |= IO::READABLE if readable && !readable.empty?
|
|
35
|
+
ready |= IO::WRITABLE if writable && !writable.empty?
|
|
36
|
+
ready.zero? ? false : ready
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def io_select(readers, writers, excepts, timeout = nil)
|
|
40
|
+
@io_select_calls += 1
|
|
41
|
+
blocking_io { IO.select(readers, writers, excepts, timeout) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def kernel_sleep(duration = nil)
|
|
45
|
+
blocking_io { sleep(duration || 0) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def block(_blocker, timeout = nil)
|
|
49
|
+
blocking_io { sleep(timeout || 0) }
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def unblock(*)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def close
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def fiber_interrupt(*)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def blocking_io(&block)
|
|
65
|
+
if Fiber.respond_to?(:blocking)
|
|
66
|
+
Fiber.blocking(&block)
|
|
67
|
+
else
|
|
68
|
+
Fiber.new(blocking: true, &block).resume
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# io_select is an optional hook; many schedulers only implement io_wait.
|
|
74
|
+
class IoWaitOnlyScheduler < RecordingScheduler
|
|
75
|
+
undef_method :io_select
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Counts Async's io_select calls, each of which starts a Thread. Prepended
|
|
79
|
+
# to a single scheduler's singleton class so other tests are unaffected.
|
|
80
|
+
module AsyncIoSelectCounter
|
|
81
|
+
attr_reader :curb_io_select_calls
|
|
82
|
+
|
|
83
|
+
def io_select(*args)
|
|
84
|
+
@curb_io_select_calls = (@curb_io_select_calls || 0) + 1
|
|
85
|
+
super
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
STREAM_CHUNKS = 32
|
|
90
|
+
STREAM_BYTES = 8192
|
|
91
|
+
|
|
92
|
+
def setup
|
|
93
|
+
super
|
|
94
|
+
omit('The socket-action loop is not built on Windows') if WINDOWS
|
|
95
|
+
omit('Fiber scheduler API unavailable on this Ruby') unless Fiber.respond_to?(:set_scheduler) && Fiber.respond_to?(:schedule)
|
|
96
|
+
omit('socket-action perform path is not available in this build') unless Curl::Multi.private_method_defined?(:_socket_perform)
|
|
97
|
+
|
|
98
|
+
@server = CurbTinyHTTPServer.new
|
|
99
|
+
@original_default_timeout = Curl::Multi.default_timeout
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def teardown
|
|
103
|
+
Curl::Multi.default_timeout = @original_default_timeout if @original_default_timeout
|
|
104
|
+
# Easy#perform under a scheduler keeps a per-thread shared multi; drop it so
|
|
105
|
+
# later tests do not inherit state from this file's Async runs.
|
|
106
|
+
state = Thread.current.thread_variable_get(:curb_scheduler_state)
|
|
107
|
+
state[:multi].close if state && state[:multi]
|
|
108
|
+
Thread.current.thread_variable_set(:curb_scheduler_state, nil)
|
|
109
|
+
@server.close if @server
|
|
110
|
+
super
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def poller
|
|
114
|
+
Curl::Multi.send(:_socket_poller)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def monotonic
|
|
118
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def with_scheduler(scheduler)
|
|
122
|
+
previous_scheduler = Fiber.scheduler if Fiber.respond_to?(:scheduler)
|
|
123
|
+
Fiber.set_scheduler(scheduler)
|
|
124
|
+
fiber = Fiber.schedule { yield }
|
|
125
|
+
fiber.resume while fiber.alive?
|
|
126
|
+
ensure
|
|
127
|
+
Fiber.set_scheduler(previous_scheduler)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def stream_path
|
|
131
|
+
"/stream/#{STREAM_CHUNKS}/#{STREAM_BYTES}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def expected_stream_body
|
|
135
|
+
CurbTinyHTTPServer.stream_body(STREAM_CHUNKS, STREAM_BYTES)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def perform_streams(count)
|
|
139
|
+
multi = Curl::Multi.new
|
|
140
|
+
easies = count.times.map do
|
|
141
|
+
easy = Curl::Easy.new(@server.url(stream_path))
|
|
142
|
+
multi.add(easy)
|
|
143
|
+
easy
|
|
144
|
+
end
|
|
145
|
+
multi.perform
|
|
146
|
+
easies
|
|
147
|
+
ensure
|
|
148
|
+
multi.close if multi
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def eventpoll_fd_count
|
|
152
|
+
Dir.children('/proc/self/fd').count do |fd|
|
|
153
|
+
begin
|
|
154
|
+
File.readlink("/proc/self/fd/#{fd}") == 'anon_inode:[eventpoll]'
|
|
155
|
+
rescue SystemCallError
|
|
156
|
+
false
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def test_socket_poller_reports_backend
|
|
162
|
+
assert_include [nil, 'epoll', 'kqueue'], poller
|
|
163
|
+
if RUBY_PLATFORM =~ /linux/
|
|
164
|
+
assert_equal 'epoll', poller
|
|
165
|
+
elsif RUBY_PLATFORM =~ /darwin|bsd/
|
|
166
|
+
assert_equal 'kqueue', poller
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# With several sockets in flight, every wait goes through io_wait on one
|
|
171
|
+
# descriptor (the poller), and the io_select hook is never used.
|
|
172
|
+
def test_concurrent_streams_wait_on_single_poller_descriptor
|
|
173
|
+
omit('no kernel event queue in this build') unless poller
|
|
174
|
+
scheduler = RecordingScheduler.new
|
|
175
|
+
easies = nil
|
|
176
|
+
|
|
177
|
+
with_scheduler(scheduler) { easies = perform_streams(8) }
|
|
178
|
+
|
|
179
|
+
easies.each do |easy|
|
|
180
|
+
assert_equal 200, easy.response_code
|
|
181
|
+
assert_equal expected_stream_body, easy.body_str
|
|
182
|
+
end
|
|
183
|
+
assert_equal 0, scheduler.io_select_calls, 'io_select should not be used when a poller is available'
|
|
184
|
+
assert_operator scheduler.io_wait_calls.length, :>=, 1
|
|
185
|
+
assert_equal 1, scheduler.io_wait_calls.map(&:first).uniq.length,
|
|
186
|
+
"expected every wait on one poller descriptor, saw #{scheduler.io_wait_calls.map(&:first).uniq.inspect}"
|
|
187
|
+
assert(scheduler.io_wait_calls.all? { |_fd, events| events == IO::READABLE },
|
|
188
|
+
'the poller descriptor should only be waited on for readability')
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Without io_select the old loop waited on one arbitrary socket for up to
|
|
192
|
+
# default_timeout, so an idle socket first in line delayed every other
|
|
193
|
+
# transfer. The poller wakes for whichever socket becomes ready.
|
|
194
|
+
def test_scheduler_without_io_select_is_not_delayed_by_idle_socket
|
|
195
|
+
omit('no kernel event queue in this build') unless poller
|
|
196
|
+
Curl::Multi.default_timeout = 2_000
|
|
197
|
+
scheduler = IoWaitOnlyScheduler.new
|
|
198
|
+
fast_elapsed = nil
|
|
199
|
+
hung = Curl::Easy.new(@server.url('/hang'))
|
|
200
|
+
fast = Curl::Easy.new(@server.url('/fast'))
|
|
201
|
+
|
|
202
|
+
started = monotonic
|
|
203
|
+
fast.on_complete do
|
|
204
|
+
fast_elapsed = monotonic - started
|
|
205
|
+
@server.release_hung(1)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
with_scheduler(scheduler) do
|
|
209
|
+
multi = Curl::Multi.new
|
|
210
|
+
begin
|
|
211
|
+
multi.add(hung)
|
|
212
|
+
multi.add(fast)
|
|
213
|
+
multi.perform
|
|
214
|
+
ensure
|
|
215
|
+
multi.close
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
assert_equal "fast", fast.body_str
|
|
220
|
+
assert_equal "released", hung.body_str
|
|
221
|
+
assert_not_nil fast_elapsed
|
|
222
|
+
assert_operator fast_elapsed, :<, 1.0, "fast transfer waited #{fast_elapsed.round(3)}s behind an idle socket"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def test_async_concurrent_streams_do_not_use_io_select
|
|
226
|
+
omit('Async gem not available') unless defined?(Async::Scheduler)
|
|
227
|
+
omit('no kernel event queue in this build') unless poller
|
|
228
|
+
bodies = []
|
|
229
|
+
scheduler = nil
|
|
230
|
+
|
|
231
|
+
Async do |task|
|
|
232
|
+
scheduler = Fiber.scheduler
|
|
233
|
+
scheduler.singleton_class.prepend(AsyncIoSelectCounter)
|
|
234
|
+
12.times.map do
|
|
235
|
+
task.async { bodies << Curl.get(@server.url(stream_path)).body_str }
|
|
236
|
+
end.each(&:wait)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
assert_equal 12, bodies.length
|
|
240
|
+
assert(bodies.all? { |body| body == expected_stream_body }, 'streamed bodies were not reassembled intact')
|
|
241
|
+
assert_equal 0, scheduler.curb_io_select_calls.to_i, 'Async#io_select starts a thread per call and should not be used'
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Completions that queue new requests close and open sockets within one
|
|
245
|
+
# perform, so descriptor numbers are reused while the poller stays open.
|
|
246
|
+
def test_socket_churn_within_one_perform
|
|
247
|
+
omit('Async gem not available') unless defined?(Async::Scheduler)
|
|
248
|
+
total = 30
|
|
249
|
+
bodies = []
|
|
250
|
+
started = monotonic
|
|
251
|
+
|
|
252
|
+
Async do
|
|
253
|
+
multi = Curl::Multi.new
|
|
254
|
+
remaining = total
|
|
255
|
+
add_next = lambda do
|
|
256
|
+
next if remaining.zero?
|
|
257
|
+
remaining -= 1
|
|
258
|
+
easy = Curl::Easy.new(@server.url('/fast'))
|
|
259
|
+
easy.on_complete do |completed|
|
|
260
|
+
bodies << completed.body_str
|
|
261
|
+
add_next.call
|
|
262
|
+
end
|
|
263
|
+
multi.add(easy)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
begin
|
|
267
|
+
2.times { add_next.call }
|
|
268
|
+
multi.perform
|
|
269
|
+
ensure
|
|
270
|
+
multi.close
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
assert_equal Array.new(total, "fast"), bodies
|
|
275
|
+
assert_operator monotonic - started, :<, 10.0
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# A peer that disconnects early or resets the connection must fail only its
|
|
279
|
+
# own transfer: readiness is reported, libcurl sees the error on read, and
|
|
280
|
+
# the loop neither spins nor stalls the healthy transfer.
|
|
281
|
+
def test_truncated_and_reset_transfers_fail_without_stalling
|
|
282
|
+
scheduler = RecordingScheduler.new
|
|
283
|
+
truncated = Curl::Easy.new(@server.url('/truncate'))
|
|
284
|
+
reset = Curl::Easy.new(@server.url('/reset'))
|
|
285
|
+
healthy = Curl::Easy.new(@server.url(stream_path))
|
|
286
|
+
started = monotonic
|
|
287
|
+
|
|
288
|
+
with_scheduler(scheduler) do
|
|
289
|
+
multi = Curl::Multi.new
|
|
290
|
+
begin
|
|
291
|
+
[truncated, reset, healthy].each { |easy| multi.add(easy) }
|
|
292
|
+
multi.perform
|
|
293
|
+
ensure
|
|
294
|
+
multi.close
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
assert_operator monotonic - started, :<, 5.0
|
|
299
|
+
assert_equal expected_stream_body, healthy.body_str
|
|
300
|
+
assert_not_equal 0, truncated.last_result, 'a truncated body should fail the transfer'
|
|
301
|
+
assert_not_equal 0, reset.last_result, 'a reset connection should fail the transfer'
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def test_poller_descriptor_closed_after_perform
|
|
305
|
+
omit('eventpoll descriptors are only visible on Linux') unless poller == 'epoll' && File.directory?('/proc/self/fd')
|
|
306
|
+
before = eventpoll_fd_count
|
|
307
|
+
|
|
308
|
+
with_scheduler(RecordingScheduler.new) { 3.times { perform_streams(2) } }
|
|
309
|
+
|
|
310
|
+
assert_equal before, eventpoll_fd_count
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def test_poller_descriptor_closed_when_perform_raises
|
|
314
|
+
omit('eventpoll descriptors are only visible on Linux') unless poller == 'epoll' && File.directory?('/proc/self/fd')
|
|
315
|
+
before = eventpoll_fd_count
|
|
316
|
+
|
|
317
|
+
assert_raise(PollerAbort) do
|
|
318
|
+
with_scheduler(RecordingScheduler.new) do
|
|
319
|
+
multi = Curl::Multi.new
|
|
320
|
+
begin
|
|
321
|
+
2.times { multi.add(Curl::Easy.new(@server.url('/delay/0.2'))) }
|
|
322
|
+
yields = 0
|
|
323
|
+
# Raise mid-transfer, after the loop has waited on the poller.
|
|
324
|
+
multi.perform do
|
|
325
|
+
yields += 1
|
|
326
|
+
raise PollerAbort, 'stop the drive loop' if yields >= 3
|
|
327
|
+
end
|
|
328
|
+
ensure
|
|
329
|
+
multi.close
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
assert_equal before, eventpoll_fd_count
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# If wrapping the poller descriptor in an IO fails, the error propagates and
|
|
338
|
+
# the descriptor is still closed.
|
|
339
|
+
def test_poller_descriptor_closed_when_io_wrapper_fails
|
|
340
|
+
omit('eventpoll descriptors are only visible on Linux') unless poller == 'epoll' && File.directory?('/proc/self/fd')
|
|
341
|
+
wrapper_error = Class.new(StandardError)
|
|
342
|
+
original_for_fd = IO.method(:for_fd)
|
|
343
|
+
before = eventpoll_fd_count
|
|
344
|
+
|
|
345
|
+
begin
|
|
346
|
+
redefine_io_for_fd { |*| raise wrapper_error, 'IO wrapper failed' }
|
|
347
|
+
assert_raise(wrapper_error) do
|
|
348
|
+
with_scheduler(RecordingScheduler.new) do
|
|
349
|
+
multi = Curl::Multi.new
|
|
350
|
+
begin
|
|
351
|
+
2.times { multi.add(Curl::Easy.new(@server.url('/fast'))) }
|
|
352
|
+
multi.perform
|
|
353
|
+
ensure
|
|
354
|
+
multi.close
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
ensure
|
|
359
|
+
redefine_io_for_fd(original_for_fd)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
assert_equal before, eventpoll_fd_count
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
private
|
|
366
|
+
|
|
367
|
+
def redefine_io_for_fd(callable = nil, &block)
|
|
368
|
+
previous_verbose = $VERBOSE
|
|
369
|
+
$VERBOSE = nil
|
|
370
|
+
IO.define_singleton_method(:for_fd, callable || block)
|
|
371
|
+
ensure
|
|
372
|
+
$VERBOSE = previous_verbose
|
|
373
|
+
end
|
|
374
|
+
end
|
|
@@ -70,6 +70,7 @@ class TestCurbCurlNativeCoverage < Test::Unit::TestCase
|
|
|
70
70
|
|
|
71
71
|
def test_clone_preserves_native_lists_after_original_handle_closes
|
|
72
72
|
easy = Curl::Easy.new("http://curb.invalid:#{TestServlet.port}#{TestServlet.path}")
|
|
73
|
+
easy.proxy_url = ""
|
|
73
74
|
easy.headers['X-Test'] = '1'
|
|
74
75
|
easy.proxy_headers['X-Proxy'] = '2'
|
|
75
76
|
easy.ftp_commands = ['PWD']
|
data/tests/tc_curl_postfield.rb
CHANGED
|
@@ -318,3 +318,42 @@ class TestCurbCurlPostfieldMultipartCoverage < Test::Unit::TestCase
|
|
|
318
318
|
assert_match(/Content-Type: text\/plain/, body)
|
|
319
319
|
end
|
|
320
320
|
end
|
|
321
|
+
|
|
322
|
+
class TestCurbCurlPostfieldInvalidToS < Test::Unit::TestCase
|
|
323
|
+
class BadToS
|
|
324
|
+
def to_s
|
|
325
|
+
42
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def test_to_s_with_non_string_content_to_s_result_does_not_crash
|
|
330
|
+
pf = Curl::PostField.content('name', BadToS.new)
|
|
331
|
+
body = nil
|
|
332
|
+
assert_nothing_raised { body = pf.to_s }
|
|
333
|
+
assert_kind_of String, body
|
|
334
|
+
assert body.start_with?('name=')
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def test_to_s_with_non_string_name_to_s_result_does_not_crash
|
|
338
|
+
pf = Curl::PostField.content(BadToS.new, 'value')
|
|
339
|
+
body = nil
|
|
340
|
+
assert_nothing_raised { body = pf.to_s }
|
|
341
|
+
assert_kind_of String, body
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def test_to_s_with_content_proc_returning_non_string_does_not_crash
|
|
345
|
+
pf = Curl::PostField.content('name') { BadToS.new }
|
|
346
|
+
assert_nothing_raised { pf.to_s }
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def test_to_s_content_proc_raising_error_still_propagates
|
|
350
|
+
pf = Curl::PostField.content('name') { raise 'boom' }
|
|
351
|
+
error = assert_raise(RuntimeError) { pf.to_s }
|
|
352
|
+
assert_equal 'boom', error.message
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def test_to_s_still_coerces_objects_with_valid_to_s
|
|
356
|
+
pf = Curl::PostField.content('name', 42)
|
|
357
|
+
assert_equal 'name=42', pf.to_s
|
|
358
|
+
end
|
|
359
|
+
end
|