curb 1.4.0 → 1.5.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.
data/ext/extconf.rb CHANGED
@@ -699,6 +699,7 @@ have_header('ruby/thread.h') && have_func('rb_thread_call_without_gvl', 'ruby/th
699
699
  have_header('ruby/io.h')
700
700
  # Ruby 4.x exports rb_thread_fd_select without declaring it in ruby/io.h.
701
701
  have_func('rb_thread_fd_select')
702
+ have_func('rb_io_descriptor', 'ruby/io.h')
702
703
  have_func('rb_wait_for_single_fd', 'ruby/io.h')
703
704
  have_header('ruby/fiber/scheduler.h')
704
705
  have_func('rb_fiber_scheduler_current', 'ruby/fiber/scheduler.h')
data/lib/curl/easy.rb CHANGED
@@ -53,6 +53,11 @@ module Curl
53
53
 
54
54
  def flush_deferred_multi_closes(all_threads: false)
55
55
  state = deferred_multi_close_state
56
+ # Fast path: nothing queued, so skip the mutex and list partitioning.
57
+ # A close queued concurrently by another thread is picked up next time.
58
+ queued = state[:deferred_multi_closes]
59
+ return if queued.nil? || queued.empty?
60
+
56
61
  pending = deferred_multi_close_mutex.synchronize do
57
62
  deferred_closes = (state[:deferred_multi_closes] ||= [])
58
63
 
@@ -184,10 +189,20 @@ module Curl
184
189
  set_protocol_allowlist('CURLOPT_REDIR_PROTOCOLS_STR', 'CURLOPT_REDIR_PROTOCOLS', protocols)
185
190
  end
186
191
 
187
- def safe_http!
192
+ def safe_http!(options = {})
193
+ options = Curl.__send__(:safe_http_options, options)
194
+ config = Curl.__send__(:ractor_local_state)[:safety_config] || Curl::SafetyConfig.new
195
+ limit = Curl::SafetyConfig.new
196
+ limit.max_body_bytes = options.fetch(:max_body_bytes, config.max_body_bytes)
197
+ limit.timeout = options.fetch(:timeout, config.timeout)
198
+ limit.max_redirects = options.fetch(:max_redirects, config.max_redirects)
188
199
  __curb_set_safety_override!(
189
200
  :protocols => [:http, :https],
190
- :redirect_protocols => [:http, :https]
201
+ :redirect_protocols => [:http, :https],
202
+ :network_policy => :public,
203
+ :max_body_bytes => limit.max_body_bytes,
204
+ :timeout => limit.timeout,
205
+ :max_redirects => limit.max_redirects
191
206
  )
192
207
  end
193
208
 
@@ -197,7 +212,10 @@ module Curl
197
212
  @__curb_safety_override = (defined?(@__curb_safety_override) && @__curb_safety_override) ? @__curb_safety_override.dup : {}
198
213
  @__curb_safety_override[:protocols] = Array(options[:protocols]).map { |protocol| protocol.to_s.downcase.to_sym } if options.key?(:protocols)
199
214
  @__curb_safety_override[:redirect_protocols] = Array(options[:redirect_protocols]).map { |protocol| protocol.to_s.downcase.to_sym } if options.key?(:redirect_protocols)
200
- @__curb_safety_override[:max_body_bytes] = options[:max_body_bytes] if options.key?(:max_body_bytes) && options[:max_body_bytes]
215
+ @__curb_safety_override[:max_body_bytes] = options[:max_body_bytes] if options.key?(:max_body_bytes)
216
+ @__curb_safety_override[:network_policy] = options[:network_policy] if options.key?(:network_policy)
217
+ @__curb_safety_override[:timeout] = options[:timeout] if options.key?(:timeout)
218
+ @__curb_safety_override[:max_redirects] = options[:max_redirects] if options.key?(:max_redirects)
201
219
  @__curb_safety_override_generation = __curb_safety_override_generation + 1
202
220
  Curl.__send__(:apply_safety!, self) if Curl.respond_to?(:apply_safety!, true)
203
221
  self
@@ -263,7 +281,9 @@ module Curl
263
281
  # the configured HTTP Verb.
264
282
  #
265
283
  def perform
266
- Curl.__send__(:apply_safety!, self) if Curl.respond_to?(:apply_safety!, true)
284
+ # Validate eagerly so an unsafe request raises in the caller (and not in
285
+ # a shared scheduler driver fiber). Multi#add re-applies it as well.
286
+ Curl.__send__(:apply_safety!, self) if Curl.__send__(:safety_active_for?, self)
267
287
  self.class.flush_deferred_multi_closes
268
288
 
269
289
  if Curl.scheduler_active? && self.multi.nil?
data/lib/curl/multi.rb CHANGED
@@ -353,6 +353,15 @@ module Curl
353
353
  ]
354
354
  end
355
355
 
356
+ # True when no per-easy safety state (override or native policy) is set.
357
+ # Callers must separately check that no global Curl.safe! config exists.
358
+ def __curb_safety_unconstrained?(easy)
359
+ easy.instance_variable_get(:@__curb_safety_override).nil? &&
360
+ easy.network_policy == :none &&
361
+ easy.allowed_hosts.nil? &&
362
+ easy.allowed_cidrs.nil?
363
+ end
364
+
356
365
  def __record_native_safety_signature(easy)
357
366
  __curb_native_safety_signatures[easy.object_id] = __curb_safety_signature_for(easy)
358
367
  end
@@ -419,24 +428,33 @@ module Curl
419
428
 
420
429
  private :__idle_easy_references, :__curb_native_safety_signatures,
421
430
  :__curb_safety_signature_for, :__record_native_safety_signature,
431
+ :__curb_safety_unconstrained?,
422
432
  :__register_idle_easy_reference,
423
433
  :__unregister_idle_easy_reference, :__clear_idle_easy_references,
424
434
  :__new_idle_easy_references
425
435
 
426
436
  alias_method :_curb_native_perform, :perform
427
437
 
428
- def perform(*args, &block)
438
+ def perform(&block)
439
+ safety_config_active = !Curl.__send__(:ractor_local_state)[:safety_config].nil?
440
+ signatures = __curb_native_safety_signatures
441
+
429
442
  requests.each_value do |easy|
430
- Curl.__send__(:apply_safety!, easy) if Curl.respond_to?(:apply_safety!, true)
443
+ # An easy added with no safety state recorded no signature. If it is
444
+ # still unconstrained its signature would be unchanged, so there is
445
+ # nothing to apply and no need to re-run native setup.
446
+ next if !safety_config_active && !signatures.key?(easy.object_id) &&
447
+ __curb_safety_unconstrained?(easy)
448
+
449
+ Curl.__send__(:apply_safety!, easy)
431
450
  signature = __curb_safety_signature_for(easy)
432
- if __curb_native_safety_signatures[easy.object_id] != signature &&
433
- easy.respond_to?(:__curb_native_setup!, true)
451
+ if signatures[easy.object_id] != signature
434
452
  easy.__send__(:__curb_native_setup!)
435
- __curb_native_safety_signatures[easy.object_id] = signature
453
+ signatures[easy.object_id] = signature
436
454
  end
437
455
  end
438
456
 
439
- _curb_native_perform(*args, &block)
457
+ _curb_native_perform(&block)
440
458
  end
441
459
 
442
460
  def add(easy)
@@ -444,19 +462,23 @@ module Curl
444
462
  # Once a deferred callback exception is pending, Multi#perform is
445
463
  # draining existing transfers only and must not start replacement work.
446
464
  return self if instance_variable_defined?(:@__curb_deferred_exception)
447
- Curl.__send__(:apply_safety!, easy) if Curl.respond_to?(:apply_safety!, true)
465
+ unconstrained = Curl.__send__(:ractor_local_state)[:safety_config].nil? &&
466
+ __curb_safety_unconstrained?(easy)
467
+ Curl.__send__(:apply_safety!, easy) unless unconstrained
448
468
  _add(easy)
449
469
  __unregister_idle_easy_reference(easy)
450
470
  requests[easy.object_id] = easy
451
- __record_native_safety_signature(easy)
471
+ # Unconstrained easies record no signature; #perform treats a missing
472
+ # signature as "added with no safety state" (see #perform).
473
+ __record_native_safety_signature(easy) unless unconstrained
452
474
  self
453
475
  end
454
476
 
455
477
  def remove(easy)
456
478
  return self if !requests[easy.object_id]
479
+ _remove(easy)
457
480
  requests.delete(easy.object_id)
458
481
  __curb_native_safety_signatures.delete(easy.object_id)
459
- _remove(easy)
460
482
  self
461
483
  end
462
484
 
data/lib/curl.rb CHANGED
@@ -12,9 +12,14 @@ module Curl
12
12
  # Ractors, even when the value itself would otherwise be safe. Keep curb's
13
13
  # mutable Ruby-side configuration in storage owned by the current Ractor.
14
14
  # Older Rubies retain the historical process-wide state.
15
+ # Ractor capabilities can't change at runtime, so probe them once instead of
16
+ # on every call: this lookup sits on the per-request path.
17
+ RACTOR_LOCAL_STORAGE = !!(defined?(Ractor) && Ractor.respond_to?(:current) &&
18
+ Ractor.current.respond_to?(:[]) && Ractor.current.respond_to?(:[]=))
19
+ private_constant :RACTOR_LOCAL_STORAGE
20
+
15
21
  def self.ractor_local_state
16
- if defined?(Ractor) && Ractor.respond_to?(:current) &&
17
- Ractor.current.respond_to?(:[]) && Ractor.current.respond_to?(:[]=)
22
+ if RACTOR_LOCAL_STORAGE
18
23
  Ractor.current[:__curb_ractor_local_state] ||= {}
19
24
  else
20
25
  @legacy_local_state ||= {}
@@ -23,15 +28,20 @@ module Curl
23
28
 
24
29
  class SafetyConfig
25
30
  DEFAULT_PROTOCOLS = [:http, :https].freeze
31
+ DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
32
+ DEFAULT_TIMEOUT = 60
33
+ DEFAULT_MAX_REDIRECTS = 20
26
34
 
27
- attr_reader :max_body_bytes, :network_policy
35
+ attr_reader :max_body_bytes, :network_policy, :timeout, :max_redirects
28
36
  attr_accessor :allow_proxies, :allow_resolve, :allow_connect_to, :allow_doh, :allow_unix_socket
29
37
 
30
38
  def initialize
31
39
  @protocols = DEFAULT_PROTOCOLS
32
40
  @redirect_protocols = nil
33
- @max_body_bytes = nil
34
- @network_policy = nil
41
+ @max_body_bytes = DEFAULT_MAX_BODY_BYTES
42
+ @timeout = DEFAULT_TIMEOUT
43
+ @max_redirects = DEFAULT_MAX_REDIRECTS
44
+ @network_policy = :public
35
45
  @allowed_hosts = nil
36
46
  @allowed_proxy_hosts = nil
37
47
  @allowed_cidrs = nil
@@ -82,6 +92,32 @@ module Curl
82
92
  @max_body_bytes = bytes.zero? ? nil : bytes
83
93
  end
84
94
 
95
+ # Seconds; applied to handles with no timeout of their own. nil disables.
96
+ def timeout=(seconds)
97
+ if seconds.nil?
98
+ @timeout = nil
99
+ return
100
+ end
101
+
102
+ seconds = Float(seconds)
103
+ raise ArgumentError, "timeout must be greater than zero" unless seconds > 0
104
+
105
+ @timeout = seconds
106
+ end
107
+
108
+ # Applied to handles whose max_redirects is unset or unlimited. nil disables.
109
+ def max_redirects=(count)
110
+ if count.nil?
111
+ @max_redirects = nil
112
+ return
113
+ end
114
+
115
+ count = Integer(count)
116
+ raise ArgumentError, "max_redirects must be greater than or equal to zero" if count < 0
117
+
118
+ @max_redirects = count
119
+ end
120
+
85
121
  def network_policy=(policy)
86
122
  @network_policy = normalize_network_policy(policy)
87
123
  end
@@ -217,16 +253,35 @@ module Curl
217
253
  apply_allowed_hosts!(easy, config.allowed_hosts) if config&.allowed_hosts
218
254
  apply_allowed_cidrs!(easy, config) if config&.allowed_cidrs
219
255
 
220
- if config&.network_policy
221
- easy.network_policy = config.network_policy
222
- apply_public_network_policy_controls!(easy, config) if config.network_policy == :public
256
+ network_policy = config&.network_policy
257
+ network_policy = :public if override && override[:network_policy] == :public
258
+ if network_policy
259
+ easy.network_policy = network_policy
260
+ apply_public_network_policy_controls!(easy, config || SafetyConfig.new) if network_policy == :public
223
261
  end
224
262
 
225
263
  apply_max_body_bytes!(easy, config.max_body_bytes) if config&.max_body_bytes
226
264
  apply_max_body_bytes!(easy, override[:max_body_bytes]) if override && override.key?(:max_body_bytes)
265
+ apply_default_timeout!(easy, override && override.key?(:timeout) ? override[:timeout] : config&.timeout)
266
+ apply_default_max_redirects!(easy, override && override.key?(:max_redirects) ? override[:max_redirects] : config&.max_redirects)
227
267
  easy
228
268
  end
229
269
 
270
+ # Only fills in a limit the handle has not chosen itself.
271
+ def self.apply_default_timeout!(easy, seconds)
272
+ return unless seconds
273
+ return unless easy.timeout_ms.to_i.zero?
274
+
275
+ easy.timeout = seconds
276
+ end
277
+
278
+ def self.apply_default_max_redirects!(easy, count)
279
+ return unless count
280
+
281
+ current = easy.max_redirects
282
+ easy.max_redirects = count if current.nil? || current < 0
283
+ end
284
+
230
285
  def self.clear_safe!
231
286
  ractor_local_state[:safety_config] = nil
232
287
  bump_safety_generation!
@@ -247,9 +302,7 @@ module Curl
247
302
  end
248
303
 
249
304
  def self.safety_override_for(easy)
250
- if easy.respond_to?(:__curb_safety_override, true)
251
- easy.__send__(:__curb_safety_override)
252
- end
305
+ easy.instance_variable_get(:@__curb_safety_override)
253
306
  end
254
307
 
255
308
  def self.safety_generation
@@ -414,6 +467,7 @@ module Curl
414
467
  :safety_generation, :bump_safety_generation!,
415
468
  :safety_protocol_intersection,
416
469
  :apply_max_body_bytes!, :apply_public_network_policy_controls!,
470
+ :apply_default_timeout!, :apply_default_max_redirects!,
417
471
  :apply_allowed_hosts!, :apply_allowed_cidrs!,
418
472
  :apply_allowed_proxy!, :normalize_proxy_host,
419
473
  :reject_resolve_override!, :reject_connect_to_override!,
@@ -421,6 +475,12 @@ module Curl
421
475
  :reject_unix_socket_override!,
422
476
  :disable_proxy!
423
477
 
478
+ # Multi#perform under a fiber scheduler uses the socket-action loop, which
479
+ # waits through the scheduler, exactly when this native method exists
480
+ # (same build condition). Otherwise it falls back to a thread-blocking wait.
481
+ SCHEDULER_COOPERATIVE_PERFORM = Curl::Multi.private_method_defined?(:_socket_perform)
482
+ private_constant :SCHEDULER_COOPERATIVE_PERFORM
483
+
424
484
  def self.scheduler_active?
425
485
  Fiber.respond_to?(:scheduler) && !Fiber.scheduler.nil?
426
486
  end
@@ -600,7 +660,11 @@ module Curl
600
660
  state[:multi].perform do
601
661
  drain_scheduler_pending(state)
602
662
  release_scheduler_waiters(state)
603
- scheduler_yield
663
+ # The socket-action loop already waits through the scheduler on
664
+ # every pass, so sibling fibers run without an extra yield (which
665
+ # cost ~4 yields per request). The fallback loop blocks the thread
666
+ # while waiting, so it still needs one.
667
+ scheduler_yield unless SCHEDULER_COOPERATIVE_PERFORM
604
668
  end
605
669
  ensure
606
670
  # Release any siblings that completed just before a deferred
@@ -653,8 +717,7 @@ module Curl
653
717
 
654
718
  http(verb, url, post_body, put_data) do |handle|
655
719
  yield handle if block
656
- handle.safe_http!
657
- handle.max_body_bytes = options[:max_body_bytes] if options.key?(:max_body_bytes)
720
+ handle.safe_http!(options)
658
721
  end
659
722
  end
660
723
 
@@ -755,7 +818,7 @@ module Curl
755
818
  end
756
819
 
757
820
  def self.safe_http_option_keys
758
- [:max_body_bytes]
821
+ [:max_body_bytes, :timeout, :max_redirects]
759
822
  end
760
823
 
761
824
  def self.reset
@@ -4,7 +4,7 @@ class BugTestInstancePostDiffersFromClassPost < Test::Unit::TestCase
4
4
  include BugTestServerSetupTeardown
5
5
 
6
6
  def setup
7
- @port = unused_local_port
7
+ @port = :auto
8
8
  @response_proc = lambda do|res|
9
9
  sleep 0.5
10
10
  res.body = "hi"
@@ -4,7 +4,7 @@ class BugFollowRedirect288 < Test::Unit::TestCase
4
4
  include BugTestServerSetupTeardown
5
5
 
6
6
  def setup
7
- @port = unused_local_port
7
+ @port = :auto
8
8
  super
9
9
  @server.mount_proc("/redirect_to_test") do|req,res|
10
10
  res.set_redirect(WEBrick::HTTPStatus::TemporaryRedirect, "/test")
data/tests/bug_poison.rb CHANGED
@@ -5,7 +5,7 @@ class BugTestPoisonResponse < Test::Unit::TestCase
5
5
  include BugTestServerSetupTeardown
6
6
 
7
7
  def setup
8
- @port = unused_local_port
8
+ @port = :auto
9
9
  @response_proc = lambda do|res|
10
10
  sleep 0.5
11
11
  res.body = "hi"
@@ -4,7 +4,7 @@ class BugRaiseOnCallback < Test::Unit::TestCase
4
4
  include BugTestServerSetupTeardown
5
5
 
6
6
  def setup
7
- @port = unused_local_port
7
+ @port = :auto
8
8
  super
9
9
  end
10
10
 
data/tests/helper.rb CHANGED
@@ -206,16 +206,17 @@ class TestServlet < WEBrick::HTTPServlet::AbstractServlet
206
206
  end
207
207
 
208
208
  module BugTestServerSetupTeardown
209
- def unused_local_port
210
- socket = TCPServer.new('127.0.0.1', 0)
211
- socket.addr[1]
212
- ensure
213
- socket.close if socket
214
- end
215
-
216
209
  def setup
217
210
  @port ||= 9992
218
- @server = WEBrick::HTTPServer.new(:Port => @port, :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
211
+ # Bind 127.0.0.1 explicitly: with no bind address, WEBrick silently keeps
212
+ # just the IPv6 listener if the IPv4 bind fails, leaving 127.0.0.1:@port
213
+ # refusing connections. :auto lets the kernel pick a free port at bind
214
+ # time instead of reserving one and re-binding it later, which could race
215
+ # with client connections reusing that ephemeral port (seen on macOS).
216
+ @server = WEBrick::HTTPServer.new(:Port => (@port == :auto ? 0 : @port),
217
+ :BindAddress => '127.0.0.1',
218
+ :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
219
+ @port = @server.config[:Port]
219
220
  @server.mount_proc("/test") do|req,res|
220
221
  if @response_proc
221
222
  @response_proc.call(res)