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,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']
@@ -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