curb 1.2.2 → 1.3.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,130 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
+
3
+ class TestCurbCurlErrorMappings < Test::Unit::TestCase
4
+ def assert_easy_error_mapping(code, expected_class)
5
+ actual_class, message = Curl::Easy.error(code)
6
+
7
+ assert_equal expected_class, actual_class
8
+ assert_kind_of String, message
9
+ assert_not_empty message
10
+ end
11
+
12
+ def test_easy_error_known_mappings
13
+ assert_easy_error_mapping(0, Curl::Err::CurlOK)
14
+ assert_easy_error_mapping(1, Curl::Err::UnsupportedProtocolError)
15
+ assert_easy_error_mapping(2, Curl::Err::FailedInitError)
16
+ assert_easy_error_mapping(3, Curl::Err::MalformedURLError)
17
+ assert_easy_error_mapping(5, Curl::Err::ProxyResolutionError)
18
+ assert_easy_error_mapping(6, Curl::Err::HostResolutionError)
19
+ assert_easy_error_mapping(7, Curl::Err::ConnectionFailedError)
20
+ assert_easy_error_mapping(23, Curl::Err::WriteError)
21
+ assert_easy_error_mapping(26, Curl::Err::ReadError)
22
+ assert_easy_error_mapping(28, Curl::Err::TimeoutError)
23
+ assert_easy_error_mapping(42, Curl::Err::AbortedByCallbackError)
24
+ assert_easy_error_mapping(47, Curl::Err::TooManyRedirectsError)
25
+ assert_easy_error_mapping(52, Curl::Err::GotNothingError)
26
+ assert_easy_error_mapping(55, Curl::Err::SendError)
27
+ assert_easy_error_mapping(56, Curl::Err::RecvError)
28
+ assert_easy_error_mapping(57, Curl::Err::ShareInUseError)
29
+ assert_easy_error_mapping(58, Curl::Err::SSLCertificateError)
30
+ assert_easy_error_mapping(59, Curl::Err::SSLCypherError)
31
+ assert_easy_error_mapping(61, Curl::Err::BadContentEncodingError)
32
+ assert_easy_error_mapping(63, Curl::Err::FileSizeExceededError)
33
+ assert_easy_error_mapping(64, Curl::Err::FTPSSLFailed)
34
+
35
+ ssl_peer_or_ca_error =
36
+ if Gem::Version.new(Curl::CURL_VERSION) >= Gem::Version.new('7.62.0')
37
+ Curl::Err::SSLPeerCertificateError
38
+ else
39
+ Curl::Err::SSLCACertificateError
40
+ end
41
+
42
+ assert_easy_error_mapping(60, ssl_peer_or_ca_error)
43
+ end
44
+
45
+ def test_easy_error_returns_error_info_for_known_numeric_range
46
+ 0.upto(92) do |code|
47
+ error_class, message = Curl::Easy.error(code)
48
+
49
+ assert_kind_of Class, error_class
50
+ assert error_class <= Curl::Err::CurlError
51
+ assert_kind_of String, message
52
+ assert_not_empty message
53
+ end
54
+ end
55
+
56
+ def test_easy_error_uses_generic_mapping_for_unknown_codes
57
+ error_class, message = Curl::Easy.error(9_999)
58
+
59
+ assert_equal Curl::Err::CurlError, error_class
60
+ assert_equal 'Unknown error result from libcurl', message
61
+ end
62
+ end
63
+
64
+ class TestCurbCurlNativeCoverage < Test::Unit::TestCase
65
+ include TestServerMethods
66
+
67
+ def setup
68
+ server_setup
69
+ end
70
+
71
+ def test_clone_preserves_native_lists_after_original_handle_closes
72
+ easy = Curl::Easy.new("http://curb.invalid:#{TestServlet.port}#{TestServlet.path}")
73
+ easy.headers['X-Test'] = '1'
74
+ easy.proxy_headers['X-Proxy'] = '2'
75
+ easy.ftp_commands = ['PWD']
76
+ easy.resolve = ["curb.invalid:#{TestServlet.port}:127.0.0.1"]
77
+
78
+ clone = easy.clone
79
+ easy.close
80
+
81
+ clone.http_get
82
+
83
+ assert_equal 'GET', clone.body_str
84
+ assert_equal '1', clone.headers['X-Test']
85
+ assert_equal '2', clone.proxy_headers['X-Proxy']
86
+ assert_equal ['PWD'], clone.ftp_commands
87
+ assert_equal ["curb.invalid:#{TestServlet.port}:127.0.0.1"], clone.resolve
88
+ ensure
89
+ clone.close if defined?(clone) && clone
90
+ easy.close if defined?(easy) && easy
91
+ end
92
+
93
+ def test_native_accessors_round_trip
94
+ easy = Curl::Easy.new(TestServlet.url)
95
+
96
+ assert_equal '127.0.0.1', easy.interface = '127.0.0.1'
97
+ assert_equal 'user:pass', easy.userpwd = 'user:pass'
98
+ assert_equal 'proxy:pass', easy.proxypwd = 'proxy:pass'
99
+ assert_equal 'tests/cert.pem', easy.cert_key = 'tests/cert.pem'
100
+ assert_equal 'gzip', easy.encoding = 'gzip'
101
+
102
+ if easy.respond_to?(:max_send_speed_large=)
103
+ assert_equal 123, easy.max_send_speed_large = 123
104
+ assert_equal 123, easy.max_send_speed_large
105
+ end
106
+
107
+ if easy.respond_to?(:max_recv_speed_large=)
108
+ assert_equal 456, easy.max_recv_speed_large = 456
109
+ assert_equal 456, easy.max_recv_speed_large
110
+ end
111
+
112
+ assert_equal '127.0.0.1', easy.interface
113
+ assert_equal 'user:pass', easy.userpwd
114
+ assert_equal 'proxy:pass', easy.proxypwd
115
+ assert_equal 'tests/cert.pem', easy.cert_key
116
+ assert_equal 'gzip', easy.encoding
117
+ ensure
118
+ easy.close if defined?(easy) && easy
119
+ end
120
+
121
+ def test_upload_round_trips_stream_and_offset
122
+ upload = Curl::Upload.new
123
+ stream = StringIO.new('payload')
124
+
125
+ assert_same stream, upload.stream = stream
126
+ assert_same stream, upload.stream
127
+ assert_equal 7, upload.offset = 7
128
+ assert_equal 7, upload.offset
129
+ end
130
+ end
@@ -142,3 +142,164 @@ class TestCurbCurlPostfield < Test::Unit::TestCase
142
142
  #assert_raise(Curl::Err::InvalidPostFieldError) { pf.to_s }
143
143
  end
144
144
  end
145
+
146
+ class TestCurbCurlPostfieldNativeCoverage < Test::Unit::TestCase
147
+ def test_attribute_writers_round_trip
148
+ pf = Curl::PostField.content('foo', 'bar')
149
+
150
+ assert_equal 'renamed', pf.name = 'renamed'
151
+ assert_equal 'payload', pf.content = 'payload'
152
+ assert_equal 'text/plain', pf.content_type = 'text/plain'
153
+ assert_equal 'local.txt', pf.local_file = 'local.txt'
154
+ assert_equal 'remote.txt', pf.remote_file = 'remote.txt'
155
+
156
+ assert_equal 'renamed', pf.name
157
+ assert_equal 'payload', pf.content
158
+ assert_equal 'text/plain', pf.content_type
159
+ assert_equal 'local.txt', pf.local_file
160
+ assert_equal 'remote.txt', pf.remote_file
161
+ end
162
+
163
+ def test_to_s_accepts_non_string_name_via_to_s
164
+ name_like = Object.new
165
+ def name_like.to_s
166
+ 'fancy name'
167
+ end
168
+
169
+ pf = Curl::PostField.content(name_like, 'value')
170
+ assert_equal 'fancy%20name=value', pf.to_s
171
+ end
172
+
173
+ def test_to_s_uses_remote_file_when_local_file_is_missing
174
+ pf = Curl::PostField.file('upload', 'local.txt', 'remote.txt')
175
+ pf.local_file = nil
176
+
177
+ assert_equal 'upload=remote.txt', pf.to_s
178
+ end
179
+
180
+ def test_to_s_rejects_name_without_to_s
181
+ name_like = Class.new do
182
+ undef to_s
183
+ end.new
184
+
185
+ pf = Curl::PostField.content(name_like, 'value')
186
+
187
+ error = assert_raise(Curl::Err::InvalidPostFieldError) { pf.to_s }
188
+ assert_match(/Cannot convert unnamed field to string/, error.message)
189
+ end
190
+
191
+ def test_to_s_rejects_content_without_to_s
192
+ content_like = Class.new do
193
+ undef to_s
194
+ end.new
195
+
196
+ pf = Curl::PostField.content('name', content_like)
197
+
198
+ error = assert_raise(RuntimeError) { pf.to_s }
199
+ assert_match(/does not respond_to to_s/, error.message)
200
+ end
201
+
202
+ def test_multipart_rejects_unnamed_field
203
+ curl = Curl::Easy.new(TestServlet.url)
204
+ curl.multipart_form_post = true
205
+ pf = Curl::PostField.content('name', 'value')
206
+ pf.name = nil
207
+
208
+ error = assert_raise(Curl::Err::InvalidPostFieldError) { curl.http_post(pf) }
209
+ assert_match(/Cannot post unnamed field/, error.message)
210
+ end
211
+
212
+ def test_multipart_rejects_content_field_without_data
213
+ curl = Curl::Easy.new(TestServlet.url)
214
+ curl.multipart_form_post = true
215
+ pf = Curl::PostField.content('name', 'value')
216
+ pf.content = nil
217
+
218
+ error = assert_raise(Curl::Err::InvalidPostFieldError) { curl.http_post(pf) }
219
+ assert_match(/Cannot post content field with no data/, error.message)
220
+ end
221
+
222
+ def test_multipart_rejects_file_field_without_filename
223
+ curl = Curl::Easy.new(TestServlet.url)
224
+ curl.multipart_form_post = true
225
+ pf = Curl::PostField.file('upload', 'remote.txt') { 'payload' }
226
+ pf.local_file = 'dummy.txt'
227
+ pf.remote_file = nil
228
+
229
+ error = assert_raise(Curl::Err::InvalidPostFieldError) { curl.http_post(pf) }
230
+ assert_match(/Cannot post file upload field with no filename/, error.message)
231
+ end
232
+ end
233
+
234
+ class TestCurbCurlPostfieldMultipartCoverage < Test::Unit::TestCase
235
+ include TestServerMethods
236
+
237
+ def setup
238
+ server_setup
239
+ end
240
+
241
+ def test_multipart_content_variants_include_dynamic_and_typed_fields
242
+ curl = Curl::Easy.new(TestServlet.url)
243
+ curl.multipart_form_post = true
244
+
245
+ proc_without_type = Curl::PostField.content('proc_without_type') { 'alpha' }
246
+ proc_with_type = Curl::PostField.content('proc_with_type', 'text/plain') { 'beta' }
247
+ direct_with_type = Curl::PostField.content('direct_with_type', 'gamma', 'text/plain')
248
+
249
+ curl.http_post([proc_without_type, proc_with_type, direct_with_type])
250
+ body = curl.body_str
251
+
252
+ assert_match(/name="proc_without_type"/, body)
253
+ assert_match(/alpha/, body)
254
+ assert_match(/name="proc_with_type"/, body)
255
+ assert_match(/beta/, body)
256
+ assert_match(/name="direct_with_type"/, body)
257
+ assert_match(/gamma/, body)
258
+ assert_match(/Content-Type: text\/plain/, body)
259
+ end
260
+
261
+ def test_multipart_file_variants_include_buffered_and_local_uploads
262
+ readme = File.expand_path(File.join(File.dirname(__FILE__), '..', 'README.md'))
263
+
264
+ proc_without_type = Curl::PostField.file('proc_without_type', 'proc_without_type.txt') { 'alpha' }
265
+ proc_with_type = Curl::PostField.file('proc_with_type', 'proc_with_type.txt') { 'beta' }
266
+ proc_with_type.content_type = 'text/plain'
267
+
268
+ direct_without_type = Curl::PostField.file('direct_without_type', 'ignored.txt', 'direct_without_type.txt')
269
+ direct_without_type.content = 'gamma'
270
+ direct_without_type.local_file = nil
271
+
272
+ direct_with_type = Curl::PostField.file('direct_with_type', 'ignored.txt', 'direct_with_type.txt')
273
+ direct_with_type.content = 'delta'
274
+ direct_with_type.local_file = nil
275
+ direct_with_type.content_type = 'text/plain'
276
+
277
+ local_with_type = Curl::PostField.file('local_with_type', readme)
278
+ local_with_type.content_type = 'text/plain'
279
+
280
+ curl = Curl::Easy.new(TestServlet.url)
281
+ curl.multipart_form_post = true
282
+ curl.http_post([proc_without_type, proc_with_type, direct_without_type, direct_with_type, local_with_type])
283
+ body = curl.body_str
284
+
285
+ assert_match(/name="proc_without_type"/, body)
286
+ assert_match(/filename="proc_without_type.txt"/, body)
287
+ assert_match(/alpha/, body)
288
+
289
+ assert_match(/name="proc_with_type"/, body)
290
+ assert_match(/filename="proc_with_type.txt"/, body)
291
+ assert_match(/beta/, body)
292
+
293
+ assert_match(/name="direct_without_type"/, body)
294
+ assert_match(/filename="direct_without_type.txt"/, body)
295
+ assert_match(/gamma/, body)
296
+
297
+ assert_match(/name="direct_with_type"/, body)
298
+ assert_match(/filename="direct_with_type.txt"/, body)
299
+ assert_match(/delta/, body)
300
+
301
+ assert_match(/name="local_with_type"/, body)
302
+ assert_match(/Curb - Libcurl bindings for Ruby/, body)
303
+ assert_match(/Content-Type: text\/plain/, body)
304
+ end
305
+ end
@@ -12,6 +12,51 @@ end
12
12
  # by running multiple requests concurrently in a single thread using the Async gem.
13
13
  class TestCurbFiberScheduler < Test::Unit::TestCase
14
14
  include BugTestServerSetupTeardown
15
+ include TestServerMethods
16
+
17
+ class RecordingScheduler
18
+ attr_reader :io_wait_events
19
+
20
+ def initialize
21
+ @io_wait_events = []
22
+ end
23
+
24
+ def fiber(&block)
25
+ Fiber.new(blocking: false, &block)
26
+ end
27
+
28
+ def io_wait(io, events, timeout = nil)
29
+ @io_wait_events << events
30
+ raise TypeError, "expected Integer events, got #{events.class}" unless events.is_a?(Integer)
31
+
32
+ readers = (events & IO::READABLE) != 0 ? [io] : nil
33
+ writers = (events & IO::WRITABLE) != 0 ? [io] : nil
34
+ readable, writable = IO.select(readers, writers, nil, timeout)
35
+
36
+ ready = 0
37
+ ready |= IO::READABLE if readable && !readable.empty?
38
+ ready |= IO::WRITABLE if writable && !writable.empty?
39
+ ready.zero? ? false : ready
40
+ end
41
+
42
+ def kernel_sleep(duration = nil)
43
+ sleep(duration || 0)
44
+ end
45
+
46
+ def block(_blocker, timeout = nil)
47
+ sleep(timeout || 0)
48
+ false
49
+ end
50
+
51
+ def unblock(*)
52
+ end
53
+
54
+ def close
55
+ end
56
+
57
+ def fiber_interrupt(*)
58
+ end
59
+ end
15
60
 
16
61
  ITERS = 4
17
62
  MIN_S = 0.25
@@ -50,6 +95,8 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
50
95
  m.add(c)
51
96
  end
52
97
  m.perform
98
+ ensure
99
+ m.close if m
53
100
  end
54
101
 
55
102
  duration = Time.now - started
@@ -109,6 +156,8 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
109
156
  m.perform do
110
157
  yielded += 1
111
158
  end
159
+ ensure
160
+ m.close if m
112
161
  end
113
162
 
114
163
  assert_operator yielded, :>=, 1, 'perform did not yield block while waiting under scheduler'
@@ -130,20 +179,49 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
130
179
  c.on_complete { result = c.code }
131
180
  m.add(c)
132
181
  m.perform
182
+ ensure
183
+ m.close if m
133
184
  end
134
185
 
135
186
  assert_equal 200, result
136
187
  end
137
188
 
189
+ def test_multi_single_request_socket_perform_passes_integer_events_to_io_wait
190
+ omit('Skipping custom scheduler test on Windows') if WINDOWS
191
+ omit('Fiber scheduler API unavailable on this Ruby') unless fiber_scheduler_supported?
192
+
193
+ url = "http://127.0.0.1:#{@port}/test"
194
+ scheduler = RecordingScheduler.new
195
+ result = nil
196
+
197
+ with_scheduler(scheduler) do
198
+ m = Curl::Multi.new
199
+ c = Curl::Easy.new(url)
200
+ c.on_complete { result = c.code }
201
+ m.add(c)
202
+ unless m.respond_to?(:_socket_perform, true)
203
+ omit('socket-action perform path is not available in this build')
204
+ end
205
+ m.send(:_socket_perform)
206
+ ensure
207
+ m.close if m
208
+ end
209
+
210
+ assert_equal 200, result
211
+ assert_operator scheduler.io_wait_events.length, :>=, 1
212
+ assert scheduler.io_wait_events.all? { |events| events.is_a?(Integer) }
213
+ assert scheduler.io_wait_events.any? { |events| (events & (IO::READABLE | IO::WRITABLE)) != 0 }
214
+ end
215
+
138
216
  def test_multi_reuse_after_scheduler_perform
139
217
  unless HAS_ASYNC
140
218
  warn 'Skipping fiber scheduler test (Async gem not available)'
141
219
  return
142
220
  end
143
-
221
+
144
222
  url = "http://127.0.0.1:#{@port}/test"
145
223
  results = []
146
-
224
+
147
225
  async_run do
148
226
  m = Curl::Multi.new
149
227
  # First round
@@ -151,15 +229,224 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
151
229
  c1.on_complete { results << c1.code }
152
230
  m.add(c1)
153
231
  m.perform
154
-
232
+
155
233
  # Second round on same multi
156
234
  c2 = Curl::Easy.new(url)
157
235
  c2.on_complete { results << c2.code }
158
236
  m.add(c2)
159
237
  m.perform
238
+ ensure
239
+ m.close if m
160
240
  end
241
+
242
+ assert_equal [200, 200], results
243
+ end
161
244
 
245
+ def test_easy_perform_reuses_scheduler_multi_when_autoclose_is_enabled
246
+ if skip_no_async
247
+ return
248
+ end
249
+
250
+ url = "http://127.0.0.1:#{@port}/test"
251
+ results = []
252
+ previous_autoclose = Curl::Multi.autoclose
253
+ Curl::Multi.autoclose = true
254
+
255
+ async_run do
256
+ 2.times do
257
+ easy = Curl::Easy.new(url)
258
+ easy.perform
259
+ results << easy.code
260
+ end
261
+ end
262
+
162
263
  assert_equal [200, 200], results
264
+ ensure
265
+ Curl::Multi.autoclose = previous_autoclose if defined?(previous_autoclose)
266
+ cleanup_scheduler_state
267
+ end
268
+
269
+ def test_easy_perform_reraises_on_body_exception_under_scheduler
270
+ if skip_no_async
271
+ return
272
+ end
273
+
274
+ url = "http://127.0.0.1:#{@port}/test"
275
+
276
+ async_run do |top|
277
+ task = top.async do
278
+ c = Curl::Easy.new(url)
279
+ c.on_body { raise "body blew up" }
280
+ c.on_complete { sleep 0 }
281
+ c.perform
282
+ end
283
+
284
+ error = assert_raise(RuntimeError) do
285
+ task.wait
286
+ end
287
+
288
+ assert_equal "body blew up", error.message
289
+ end
290
+ end
291
+
292
+ def test_easy_perform_reraises_on_header_exception_under_scheduler
293
+ if skip_no_async
294
+ return
295
+ end
296
+
297
+ url = "http://127.0.0.1:#{@port}/test"
298
+
299
+ async_run do |top|
300
+ task = top.async do
301
+ c = Curl::Easy.new(url)
302
+ c.on_header { raise "header blew up" }
303
+ c.on_complete { sleep 0 }
304
+ c.perform
305
+ end
306
+
307
+ error = assert_raise(RuntimeError) do
308
+ task.wait
309
+ end
310
+
311
+ assert_equal "header blew up", error.message
312
+ end
313
+ end
314
+
315
+ def test_easy_perform_does_not_reraise_sibling_on_complete_exception_under_scheduler
316
+ if skip_no_async
317
+ return
318
+ end
319
+
320
+ with_ephemeral_http_server do |port, hits|
321
+ results = {}
322
+
323
+ async_run do |top|
324
+ successful = top.async do
325
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/fast")
326
+ easy.perform
327
+ results[:successful] = easy.response_code
328
+ rescue => e
329
+ results[:successful] = e
330
+ end
331
+
332
+ failing = top.async do
333
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/slow")
334
+ easy.on_complete { raise "boom" }
335
+ easy.perform
336
+ results[:failing] = :returned
337
+ rescue => e
338
+ results[:failing] = e
339
+ end
340
+
341
+ successful.wait
342
+ failing.wait
343
+ end
344
+
345
+ assert_equal 200, results[:successful], "successful scheduler peer should return normally"
346
+ assert_kind_of Curl::Err::AbortedByCallbackError, results[:failing]
347
+ assert_equal "boom", results[:failing].message
348
+ assert_equal 1, hits[:fast]
349
+ assert_equal 1, hits[:slow]
350
+ end
351
+ end
352
+
353
+ def test_easy_perform_does_not_reraise_fast_on_complete_exception_into_slow_successful_peer_under_scheduler
354
+ if skip_no_async
355
+ return
356
+ end
357
+
358
+ with_ephemeral_http_server do |port, hits|
359
+ results = {}
360
+
361
+ async_run do |top|
362
+ failing = top.async do
363
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/fast")
364
+ easy.on_complete { raise "boom" }
365
+ easy.perform
366
+ results[:failing] = :returned
367
+ rescue => e
368
+ results[:failing] = e
369
+ end
370
+
371
+ successful = top.async do
372
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/slow")
373
+ easy.perform
374
+ results[:successful] = easy.response_code
375
+ rescue => e
376
+ results[:successful] = e
377
+ end
378
+
379
+ failing.wait
380
+ successful.wait
381
+ end
382
+
383
+ assert_equal 200, results[:successful], "slow successful scheduler peer should not inherit a fast sibling callback error"
384
+ assert_kind_of Curl::Err::AbortedByCallbackError, results[:failing]
385
+ assert_equal "boom", results[:failing].message
386
+ assert_equal 1, hits[:fast]
387
+ assert_equal 1, hits[:slow]
388
+ end
389
+ end
390
+
391
+ def test_easy_perform_keeps_scheduler_timers_running_while_draining_deferred_on_complete_exception
392
+ if skip_no_async
393
+ return
394
+ end
395
+
396
+ with_ephemeral_http_server do |port, hits|
397
+ marks = {}
398
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
399
+
400
+ async_run do |top|
401
+ timer = top.async do
402
+ sleep 0.05
403
+ marks[:timer] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
404
+ end
405
+
406
+ failing = top.async do
407
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/fast")
408
+ easy.on_complete { raise "boom" }
409
+ easy.perform
410
+ marks[:failing] = :returned
411
+ rescue => e
412
+ marks[:failing] = e
413
+ end
414
+
415
+ successful = top.async do
416
+ easy = Curl::Easy.new("http://127.0.0.1:#{port}/slow")
417
+ easy.perform
418
+ marks[:slow_done] = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
419
+ end
420
+
421
+ timer.wait
422
+ failing.wait
423
+ successful.wait
424
+ end
425
+
426
+ assert_kind_of Curl::Err::AbortedByCallbackError, marks[:failing]
427
+ assert_operator marks[:timer], :<, marks[:slow_done] - 0.02,
428
+ "scheduler timer should fire before the slow sibling finishes draining"
429
+ assert_equal 1, hits[:fast]
430
+ assert_equal 1, hits[:slow]
431
+ end
432
+ end
433
+
434
+ def test_drain_scheduler_pending_does_not_drop_work_rejected_during_deferred_abort
435
+ state = Curl.scheduler_state
436
+ easy = Curl::Easy.new("http://127.0.0.1:#{@port}/test")
437
+ waiter = {completed: false, done: false, error: nil}
438
+
439
+ state[:waiters][easy.object_id] = waiter
440
+ state[:pending] << easy
441
+ state[:multi].instance_variable_set(:@__curb_deferred_exception, RuntimeError.new("boom"))
442
+
443
+ Curl.drain_scheduler_pending(state)
444
+
445
+ assert(state[:pending].include?(easy) || waiter[:error],
446
+ "scheduler enqueue rejected during deferred abort should remain pending or fail its waiter instead of disappearing")
447
+ assert_equal 0, state[:multi].requests.length
448
+ ensure
449
+ cleanup_scheduler_state
163
450
  end
164
451
 
165
452
  private
@@ -168,6 +455,10 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
168
455
  warn 'Skipping fiber scheduler tests on Windows'
169
456
  return true
170
457
  end
458
+ unless fiber_scheduler_supported?
459
+ warn 'Skipping fiber scheduler tests (Fiber scheduler API unavailable)'
460
+ return true
461
+ end
171
462
  unless HAS_ASYNC
172
463
  warn 'Skipping fiber scheduler test (Async gem not available)'
173
464
  return true
@@ -183,8 +474,52 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
183
474
  Async(&block)
184
475
  end
185
476
  end
186
- end
187
- if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.1')
188
- warn 'Skipping fiber scheduler tests on Ruby < 3.1'
189
- return
477
+
478
+ def fiber_scheduler_supported?
479
+ Fiber.respond_to?(:set_scheduler) && Fiber.respond_to?(:schedule)
480
+ end
481
+
482
+ def with_scheduler(scheduler)
483
+ previous_scheduler = Fiber.scheduler if Fiber.respond_to?(:scheduler)
484
+ Fiber.set_scheduler(scheduler)
485
+ fiber = Fiber.schedule { yield }
486
+ fiber.resume while fiber.alive?
487
+ ensure
488
+ Fiber.set_scheduler(previous_scheduler) if Fiber.respond_to?(:set_scheduler)
489
+ end
490
+
491
+ def cleanup_scheduler_state
492
+ state = Thread.current.thread_variable_get(:curb_scheduler_state)
493
+ state[:multi].close if state && state[:multi]
494
+ Thread.current.thread_variable_set(:curb_scheduler_state, nil)
495
+ end
496
+
497
+ def with_ephemeral_http_server
498
+ port_socket = TCPServer.new('127.0.0.1', 0)
499
+ port = port_socket.addr[1]
500
+ port_socket.close
501
+
502
+ hits = Hash.new(0)
503
+ server = WEBrick::HTTPServer.new(:Port => port, :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
504
+ server.mount_proc('/fast') do |_req, res|
505
+ hits[:fast] += 1
506
+ res['Content-Type'] = 'text/plain'
507
+ res.body = 'fast'
508
+ end
509
+ server.mount_proc('/slow') do |_req, res|
510
+ hits[:slow] += 1
511
+ res['Content-Type'] = 'text/plain'
512
+ sleep 0.2
513
+ res.body = 'slow'
514
+ end
515
+
516
+ server_thread = Thread.new(server) { |srv| srv.start }
517
+ wait_for_server_ready(port, thread: server_thread)
518
+
519
+ yield port, hits
520
+ ensure
521
+ server.shutdown if defined?(server) && server
522
+ server_thread.join(server_startup_timeout) if defined?(server_thread) && server_thread
523
+ server_thread.kill if defined?(server_thread) && server_thread&.alive?
524
+ end
190
525
  end