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.
data/ext/curb_multi.h CHANGED
@@ -21,6 +21,10 @@ typedef struct {
21
21
  char allow_close_during_perform;
22
22
  CURLM *handle;
23
23
  struct st_table *attached;
24
+ /* Bumped on every add and stamped onto the easy (multi_attachment_generation),
25
+ * so "attached after point X" is a comparison instead of a table snapshot.
26
+ * Monotonic for the object's lifetime; never reset. */
27
+ unsigned long attachment_sequence;
24
28
  } ruby_curl_multi;
25
29
 
26
30
  extern VALUE cCurlMulti;
data/ext/curb_postfield.c CHANGED
@@ -468,7 +468,10 @@ static VALUE ruby_curl_postfield_to_str(VALUE self) {
468
468
  name = rbcpf->name;
469
469
  if (TYPE(name) != T_STRING) {
470
470
  if (rb_respond_to(name, rb_intern("to_s")))
471
- name = rb_funcall(name, rb_intern("to_s"), 0);
471
+ /* rb_obj_as_string always yields a String, even if #to_s misbehaves
472
+ * and returns a non-String; a raw rb_funcall result could later be
473
+ * handed to RSTRING_PTR/RSTRING_LEN and crash. */
474
+ name = rb_obj_as_string(name);
472
475
  else
473
476
  name = Qnil;
474
477
  }
@@ -481,6 +484,33 @@ static VALUE ruby_curl_postfield_to_str(VALUE self) {
481
484
  /* Force field name to UTF-8 before escaping */
482
485
  VALUE name_utf8 = rb_str_export_to_enc(name, rb_utf8_encoding());
483
486
 
487
+ /* Resolve and coerce the content before creating the curl handle: the
488
+ * content proc, #to_s and the UTF-8 export can all raise, which would
489
+ * otherwise leak the handle. */
490
+ VALUE tmpcontent = Qnil;
491
+ if (rbcpf->content_proc != Qnil) {
492
+ tmpcontent = rb_funcall(rbcpf->content_proc, idCall, 1, self);
493
+ } else if (rbcpf->content != Qnil) {
494
+ tmpcontent = rbcpf->content;
495
+ } else if (rbcpf->local_file != Qnil) {
496
+ tmpcontent = rbcpf->local_file;
497
+ } else if (rbcpf->remote_file != Qnil) {
498
+ tmpcontent = rbcpf->remote_file;
499
+ } else {
500
+ tmpcontent = rb_str_new2("");
501
+ }
502
+ if (TYPE(tmpcontent) != T_STRING) {
503
+ if (rb_respond_to(tmpcontent, rb_intern("to_s")))
504
+ /* rb_obj_as_string always yields a String, even if #to_s misbehaves */
505
+ tmpcontent = rb_obj_as_string(tmpcontent);
506
+ else
507
+ rb_raise(rb_eRuntimeError,
508
+ "postfield(%"PRIsVALUE") is not a string and does not respond_to to_s",
509
+ name);
510
+ }
511
+ /* Force content to UTF-8 before escaping */
512
+ VALUE content_utf8 = rb_str_export_to_enc(tmpcontent, rb_utf8_encoding());
513
+
484
514
  #ifdef HAVE_CURL_EASY_ESCAPE
485
515
  curl_handle = curl_easy_init();
486
516
  if (!curl_handle) {
@@ -499,38 +529,8 @@ static VALUE ruby_curl_postfield_to_str(VALUE self) {
499
529
  #endif
500
530
 
501
531
  VALUE escd_name = rb_str_new2(tmpchrs);
502
- #ifdef HAVE_CURL_EASY_ESCAPE
503
- curl_free(tmpchrs);
504
- #else
505
532
  curl_free(tmpchrs);
506
- #endif
507
533
 
508
- VALUE tmpcontent = Qnil;
509
- if (rbcpf->content_proc != Qnil) {
510
- tmpcontent = rb_funcall(rbcpf->content_proc, idCall, 1, self);
511
- } else if (rbcpf->content != Qnil) {
512
- tmpcontent = rbcpf->content;
513
- } else if (rbcpf->local_file != Qnil) {
514
- tmpcontent = rbcpf->local_file;
515
- } else if (rbcpf->remote_file != Qnil) {
516
- tmpcontent = rbcpf->remote_file;
517
- } else {
518
- tmpcontent = rb_str_new2("");
519
- }
520
- if (TYPE(tmpcontent) != T_STRING) {
521
- if (rb_respond_to(tmpcontent, rb_intern("to_s")))
522
- tmpcontent = rb_funcall(tmpcontent, rb_intern("to_s"), 0);
523
- else {
524
- #ifdef HAVE_CURL_EASY_ESCAPE
525
- curl_easy_cleanup(curl_handle);
526
- #endif
527
- rb_raise(rb_eRuntimeError,
528
- "postfield(%s) is not a string and does not respond_to to_s",
529
- RSTRING_PTR(escd_name));
530
- }
531
- }
532
- /* Force content to UTF-8 before escaping */
533
- VALUE content_utf8 = rb_str_export_to_enc(tmpcontent, rb_utf8_encoding());
534
534
  #ifdef HAVE_CURL_EASY_ESCAPE
535
535
  tmpchrs = curl_easy_escape(curl_handle, StringValuePtr(content_utf8), (int)RSTRING_LEN(content_utf8));
536
536
  if (!tmpchrs) {
data/ext/extconf.rb CHANGED
@@ -260,6 +260,7 @@ have_constant "curl_version_idn"
260
260
  have_constant "curl_version_sspi"
261
261
  have_constant "curl_version_conv"
262
262
  have_constant "curl_version_http2"
263
+ have_constant "curl_version_threadsafe"
263
264
  have_constant "curlproxy_http"
264
265
  have_constant "curlproxy_socks4"
265
266
  have_constant "curlproxy_socks4a"
@@ -462,6 +463,9 @@ have_constant "curlopt_ftp_use_eprt"
462
463
  have_constant "curlopt_ftp_use_epsv"
463
464
  have_constant "curlopt_ftp_use_pret"
464
465
  have_constant "curlopt_ftp_create_missing_dirs"
466
+ have_constant "curlftp_create_dir_none"
467
+ have_constant "curlftp_create_dir"
468
+ have_constant "curlftp_create_dir_retry"
465
469
  have_constant "curlopt_ftp_response_timeout"
466
470
  have_constant "curlopt_ftp_alternative_to_user"
467
471
  have_constant "curlopt_ftp_skip_pasv_ip"
@@ -700,6 +704,8 @@ have_header('ruby/fiber/scheduler.h')
700
704
  have_func('rb_fiber_scheduler_current', 'ruby/fiber/scheduler.h')
701
705
  have_func('rb_fiber_scheduler_io_wait', 'ruby/fiber/scheduler.h')
702
706
  have_func('rb_fiber_scheduler_io_select', 'ruby/fiber/scheduler.h')
707
+ have_func('rb_fiber_scheduler_kernel_sleep', 'ruby/fiber/scheduler.h')
708
+ have_func('rb_ext_ractor_safe')
703
709
  have_func('rb_io_stdio_file')
704
710
  have_func('curl_multi_wait')
705
711
  have_func('curl_multi_socket_action')
@@ -710,6 +716,10 @@ have_func('curl_multi_socket')
710
716
  have_func('curl_multi_timer_callback')
711
717
  have_constant 'curlmopt_socketfunction'
712
718
  have_constant 'curlmopt_timerfunction'
719
+ # Kernel event queues let the socket-action loop wait on one descriptor for
720
+ # every libcurl socket under a fiber scheduler.
721
+ have_header('sys/epoll.h') && have_func('epoll_create1', 'sys/epoll.h')
722
+ have_header('sys/event.h') && have_func('kqueue', ['sys/types.h', 'sys/event.h'])
713
723
  have_func('curl_easy_duphandle')
714
724
 
715
725
  # Optional: enable verbose socket-action debug logging.
data/lib/curl/easy.rb CHANGED
@@ -3,13 +3,20 @@ require 'curl/download'
3
3
  module Curl
4
4
  class Easy
5
5
  class << self
6
+ def deferred_multi_close_state
7
+ Curl.__send__(:ractor_local_state)
8
+ end
9
+ private :deferred_multi_close_state
10
+
6
11
  def deferred_multi_close_mutex
7
- @deferred_multi_close_mutex ||= Mutex.new
12
+ state = deferred_multi_close_state
13
+ state[:deferred_multi_close_mutex] ||= Mutex.new
8
14
  end
9
15
 
10
16
  def deferred_multi_closes
17
+ state = deferred_multi_close_state
11
18
  deferred_multi_close_mutex.synchronize do
12
- (@deferred_multi_closes ||= []).dup
19
+ (state[:deferred_multi_closes] ||= []).dup
13
20
  end
14
21
  end
15
22
 
@@ -34,27 +41,29 @@ module Curl
34
41
  end
35
42
 
36
43
  def defer_multi_close(multi, easy, owner: Thread.current)
44
+ state = deferred_multi_close_state
37
45
  deferred_multi_close_mutex.synchronize do
38
- @deferred_multi_closes ||= []
39
- return if @deferred_multi_closes.any? { |entry| entry[:multi].equal?(multi) }
46
+ state[:deferred_multi_closes] ||= []
47
+ return if state[:deferred_multi_closes].any? { |entry| entry[:multi].equal?(multi) }
40
48
 
41
49
  multi.instance_variable_set(:@deferred_close, true)
42
- @deferred_multi_closes << { multi: multi, easy: easy, owner: owner }
50
+ state[:deferred_multi_closes] << { multi: multi, easy: easy, owner: owner }
43
51
  end
44
52
  end
45
53
 
46
54
  def flush_deferred_multi_closes(all_threads: false)
55
+ state = deferred_multi_close_state
47
56
  pending = deferred_multi_close_mutex.synchronize do
48
- @deferred_multi_closes ||= []
57
+ deferred_closes = (state[:deferred_multi_closes] ||= [])
49
58
 
50
59
  if all_threads
51
- @deferred_multi_closes.shift(@deferred_multi_closes.length)
60
+ deferred_closes.shift(deferred_closes.length)
52
61
  else
53
62
  owner = Thread.current
54
63
  remaining = []
55
64
  current = []
56
65
 
57
- @deferred_multi_closes.each do |entry|
66
+ deferred_closes.each do |entry|
58
67
  if entry[:owner].equal?(owner)
59
68
  current << entry
60
69
  else
@@ -62,7 +71,7 @@ module Curl
62
71
  end
63
72
  end
64
73
 
65
- @deferred_multi_closes = remaining
74
+ state[:deferred_multi_closes] = remaining
66
75
  current
67
76
  end
68
77
  end
data/lib/curl.rb CHANGED
@@ -8,6 +8,19 @@ require 'uri'
8
8
 
9
9
  # expose shortcut methods
10
10
  module Curl
11
+ # Module/class instance variables cannot be read or written by non-main
12
+ # Ractors, even when the value itself would otherwise be safe. Keep curb's
13
+ # mutable Ruby-side configuration in storage owned by the current Ractor.
14
+ # Older Rubies retain the historical process-wide state.
15
+ def self.ractor_local_state
16
+ if defined?(Ractor) && Ractor.respond_to?(:current) &&
17
+ Ractor.current.respond_to?(:[]) && Ractor.current.respond_to?(:[]=)
18
+ Ractor.current[:__curb_ractor_local_state] ||= {}
19
+ else
20
+ @legacy_local_state ||= {}
21
+ end
22
+ end
23
+
11
24
  class SafetyConfig
12
25
  DEFAULT_PROTOCOLS = [:http, :https].freeze
13
26
 
@@ -176,12 +189,12 @@ module Curl
176
189
  def self.safe!
177
190
  config = SafetyConfig.new
178
191
  yield config if block_given?
179
- @safety_config = config
192
+ ractor_local_state[:safety_config] = config
180
193
  bump_safety_generation!
181
194
  end
182
195
 
183
196
  def self.apply_safety!(easy)
184
- config = @safety_config
197
+ config = ractor_local_state[:safety_config]
185
198
  override = safety_override_for(easy)
186
199
  return easy unless config || override
187
200
 
@@ -215,12 +228,12 @@ module Curl
215
228
  end
216
229
 
217
230
  def self.clear_safe!
218
- @safety_config = nil
231
+ ractor_local_state[:safety_config] = nil
219
232
  bump_safety_generation!
220
233
  end
221
234
 
222
235
  def self.safety_active_for?(easy)
223
- !!(@safety_config || safety_override_for(easy))
236
+ !!(ractor_local_state[:safety_config] || safety_override_for(easy))
224
237
  end
225
238
 
226
239
  def self.safety_signature_for(easy)
@@ -240,11 +253,11 @@ module Curl
240
253
  end
241
254
 
242
255
  def self.safety_generation
243
- @safety_generation ||= 0
256
+ ractor_local_state[:safety_generation] ||= 0
244
257
  end
245
258
 
246
259
  def self.bump_safety_generation!
247
- @safety_generation = safety_generation + 1
260
+ ractor_local_state[:safety_generation] = safety_generation + 1
248
261
  end
249
262
 
250
263
  def self.safety_protocol_intersection(base_protocols, override_protocols)
@@ -395,7 +408,8 @@ module Curl
395
408
  easy.set(Curl::CURLOPT_NOPROXY, "*") if Curl.const_defined?(:CURLOPT_NOPROXY)
396
409
  end
397
410
 
398
- private_class_method :apply_safety!, :clear_safe!, :safety_active_for?,
411
+ private_class_method :ractor_local_state,
412
+ :apply_safety!, :clear_safe!, :safety_active_for?,
399
413
  :safety_signature_for, :safety_override_for,
400
414
  :safety_generation, :bump_safety_generation!,
401
415
  :safety_protocol_intersection,
@@ -480,23 +494,44 @@ module Curl
480
494
  end
481
495
  end
482
496
 
497
+ # Wakes the waiters whose transfers finished since the last call. Completions
498
+ # are queued by the on_complete wrapper in perform_with_scheduler, so each
499
+ # drive-loop pass costs O(new completions) rather than O(all waiters).
483
500
  def self.release_scheduler_waiters(state)
484
- source_id = deferred_exception_source_id(state)
501
+ completed = state[:completed]
502
+ return if completed.empty?
485
503
 
486
- state[:waiters].each do |easy_id, waiter|
487
- next if source_id == easy_id
504
+ source_id = deferred_exception_source_id(state)
505
+ retained = nil
506
+
507
+ until completed.empty?
508
+ easy_id, waiter = completed.shift
509
+ # A waiter whose perform already returned (it raised) is no longer
510
+ # blocked on this completion; waking its fiber would be spurious.
511
+ next unless state[:waiters][easy_id].equal?(waiter)
512
+
513
+ if source_id == easy_id
514
+ # The transfer that raised the deferred exception gets that error via
515
+ # release_scheduler_error. Keep it queued in case the source changes.
516
+ (retained ||= []) << [easy_id, waiter]
517
+ next
518
+ end
488
519
 
489
- complete_scheduler_waiter(waiter) if waiter[:completed]
520
+ complete_scheduler_waiter(waiter)
490
521
  end
522
+
523
+ completed.concat(retained) if retained
491
524
  end
492
525
 
493
526
  def self.perform_with_scheduler(easy)
494
527
  state = scheduler_state
528
+ easy_id = easy.object_id
495
529
  waiter = {completed: false, done: false, error: nil, fiber: nil, scheduler: Fiber.scheduler}
496
- state[:waiters][easy.object_id] = waiter
530
+ state[:waiters][easy_id] = waiter
497
531
  previous_complete = easy.on_complete do |completed_easy|
498
532
  previous_complete.call(completed_easy) if previous_complete
499
533
  waiter[:completed] = true
534
+ state[:completed] << [easy_id, waiter]
500
535
  end
501
536
 
502
537
  state[:pending] << easy
@@ -533,6 +568,8 @@ module Curl
533
568
  driver_running: false,
534
569
  error: nil,
535
570
  waiters: {},
571
+ # [easy object_id, waiter] pairs completed but not yet woken
572
+ completed: [],
536
573
  }
537
574
  Thread.current.thread_variable_set(:curb_scheduler_state, state)
538
575
  state
data/tests/helper.rb CHANGED
@@ -228,16 +228,100 @@ module BugTestServerSetupTeardown
228
228
  @thread = Thread.new(@server) do|srv|
229
229
  srv.start
230
230
  end
231
+
232
+ # WEBrick#shutdown is a no-op until start has created its shutdown pipe
233
+ # and set :Running, so wait for that before the test (and its teardown)
234
+ # runs. Otherwise a teardown racing server startup could miss the brief
235
+ # :Shutdown status and spin forever once the server settled back on :Stop.
236
+ sleep 0.001 until @server.status == :Running || !@thread.alive?
237
+ @thread.value unless @thread.alive?
231
238
  end
232
239
 
233
240
  def teardown
234
- while @server.status != :Shutdown
235
- @server.shutdown
236
- end
241
+ @server.shutdown
237
242
  @thread.join
238
243
  end
239
244
  end
240
245
 
246
+ # Test::Unit creates a new test instance for every test method. Keep the
247
+ # common WEBrick fixture process-scoped so those instances can share it while
248
+ # still allowing an explicit stop_test_server call to exercise lifecycle code.
249
+ module CurbTestServerRegistry
250
+ Entry = Struct.new(:key, :port, :server, :thread, :lock_file, :pid)
251
+
252
+ @mutex = Mutex.new
253
+ @entries = {}
254
+
255
+ class << self
256
+ def acquire(port, servlet)
257
+ key = [port, servlet]
258
+
259
+ @mutex.synchronize do
260
+ entry = @entries[key]
261
+ return entry if active?(entry)
262
+
263
+ @entries.delete(key)
264
+ entry = yield
265
+ @entries[key] = entry if entry
266
+ entry
267
+ end
268
+ end
269
+
270
+ def stop(entry, timeout)
271
+ return false unless remove(entry)
272
+
273
+ shutdown_entry(entry, timeout)
274
+ true
275
+ end
276
+
277
+ def register_shutdown_hook(entry)
278
+ # Test::Unit can run a directly executed test file from its own at_exit
279
+ # callback. Register after startup so this hook runs after that callback.
280
+ at_exit { stop(entry, 5) }
281
+ end
282
+
283
+ private
284
+
285
+ def active?(entry)
286
+ entry && entry.server && entry.thread && entry.thread.alive? && entry.server.status == :Running
287
+ rescue StandardError
288
+ false
289
+ end
290
+
291
+ def remove(entry)
292
+ @mutex.synchronize do
293
+ return false unless @entries[entry.key].equal?(entry)
294
+
295
+ @entries.delete(entry.key)
296
+ true
297
+ end
298
+ end
299
+
300
+ def shutdown_entry(entry, timeout)
301
+ return unless Process.pid == entry.pid
302
+
303
+ begin
304
+ entry.server.shutdown if entry.server
305
+ rescue StandardError
306
+ nil
307
+ end
308
+
309
+ thread = entry.thread
310
+ if thread
311
+ thread.join(timeout)
312
+ if thread.alive?
313
+ thread.kill
314
+ thread.join(timeout)
315
+ end
316
+ end
317
+
318
+ File.unlink(entry.lock_file) if entry.lock_file && File.exist?(entry.lock_file)
319
+ rescue Errno::ENOENT
320
+ nil
321
+ end
322
+ end
323
+ end
324
+
241
325
  module TestServerMethods
242
326
  def server_responding?(port)
243
327
  socket = TCPSocket.new('127.0.0.1', port)
@@ -267,6 +351,22 @@ module TestServerMethods
267
351
  end
268
352
  end
269
353
 
354
+ # Waiting on WEBrick's state avoids opening a probe connection for every
355
+ # startup. On Windows those short-lived connections make server teardown
356
+ # disproportionately expensive.
357
+ def wait_for_server_started(server, port, thread: nil)
358
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + server_startup_timeout
359
+
360
+ loop do
361
+ return true if server.status == :Running
362
+ return false if thread && !thread.alive?
363
+
364
+ raise "Failed to startup test server on port #{port}" if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
365
+
366
+ sleep 0.01
367
+ end
368
+ end
369
+
270
370
  def server_shutdown_timeout
271
371
  [server_startup_timeout, 0.25].max
272
372
  end
@@ -337,6 +437,13 @@ module TestServerMethods
337
437
  end
338
438
 
339
439
  def stop_test_server
440
+ shared_entry = instance_variable_defined?(:@__shared_test_server_entry) ? @__shared_test_server_entry : nil
441
+ if shared_entry
442
+ CurbTestServerRegistry.stop(shared_entry, server_shutdown_timeout)
443
+ release_shared_test_server
444
+ return
445
+ end
446
+
340
447
  server = instance_variable_defined?(:@server) ? @server : nil
341
448
  pid = instance_variable_defined?(:@__pid) ? @__pid : nil
342
449
  return unless server || pid
@@ -382,11 +489,28 @@ module TestServerMethods
382
489
  def teardown
383
490
  super
384
491
  ensure
385
- stop_test_server
492
+ if instance_variable_defined?(:@__shared_test_server_entry) && @__shared_test_server_entry
493
+ release_shared_test_server
494
+ else
495
+ stop_test_server
496
+ end
386
497
  end
387
498
 
388
499
  def server_setup(port=9129,servlet=TestServlet)
389
500
  @__port = port
501
+
502
+ unless TEST_SINGLE_THREADED
503
+ entry = CurbTestServerRegistry.acquire(port, servlet) do
504
+ start_shared_test_server(port, servlet)
505
+ end
506
+
507
+ if entry
508
+ attach_shared_test_server(entry)
509
+ end
510
+
511
+ return
512
+ end
513
+
390
514
  @server = nil unless instance_variable_defined?(:@server)
391
515
  @__pid = nil unless instance_variable_defined?(:@__pid)
392
516
  @test_thread = nil unless instance_variable_defined?(:@test_thread)
@@ -474,6 +598,64 @@ module TestServerMethods
474
598
  end
475
599
  rescue Errno::EADDRINUSE
476
600
  end
601
+
602
+ def attach_shared_test_server(entry)
603
+ @server = entry.server
604
+ @test_thread = entry.thread
605
+ @__pid = nil
606
+ @__shared_test_server_entry = entry
607
+ end
608
+
609
+ def release_shared_test_server
610
+ @server = nil
611
+ @test_thread = nil
612
+ @__pid = nil
613
+ @__shared_test_server_entry = nil
614
+ end
615
+
616
+ def start_shared_test_server(port, servlet)
617
+ clear_stale_server_lock(port)
618
+
619
+ if File.exist?(locked_file)
620
+ begin
621
+ wait_for_server_ready(port)
622
+ return nil
623
+ rescue RuntimeError
624
+ clear_stale_server_lock(port)
625
+ end
626
+ end
627
+
628
+ write_server_lock
629
+ server = WEBrick::HTTPServer.new(:Port => port, :DocumentRoot => File.expand_path(File.dirname(__FILE__)), :Logger => WEBRICK_TEST_LOG, :AccessLog => [])
630
+
631
+ server.mount(servlet.path, servlet)
632
+ server.mount("/ext", WEBrick::HTTPServlet::FileHandler, File.join(File.dirname(__FILE__),'..','ext'))
633
+
634
+ thread = Thread.new(server) { |srv| srv.start }
635
+ unless wait_for_server_started(server, port, thread: thread)
636
+ server.shutdown
637
+ thread.join(server_shutdown_timeout)
638
+ raise "Failed to startup test server on port #{port}"
639
+ end
640
+
641
+ entry = CurbTestServerRegistry::Entry.new([port, servlet], port, server, thread, locked_file, Process.pid)
642
+ CurbTestServerRegistry.register_shutdown_hook(entry)
643
+ entry
644
+ rescue Errno::EADDRINUSE
645
+ File.unlink(locked_file) if File.exist?(locked_file)
646
+ return nil if server_responding?(port)
647
+
648
+ raise
649
+ rescue StandardError
650
+ begin
651
+ server.shutdown if defined?(server) && server
652
+ thread.join(server_shutdown_timeout) if defined?(thread) && thread
653
+ rescue StandardError
654
+ nil
655
+ end
656
+ File.unlink(locked_file) if File.exist?(locked_file)
657
+ raise
658
+ end
477
659
  end
478
660
 
479
661
 
@@ -553,3 +735,117 @@ end
553
735
  unless URI.methods.include?(:encode_www_form)
554
736
  URI.extend(Backports::Ruby18::URIFormEncoding)
555
737
  end
738
+
739
+ # Valgrind slows every request enough that tight wall-clock bounds stop
740
+ # meaning anything; tests use this to relax or skip only those bounds.
741
+ def curb_under_valgrind?
742
+ ENV['LD_PRELOAD'].to_s.include?('vgpreload')
743
+ end
744
+
745
+ # Queue#pop(timeout:) needs Ruby 3.2. Older Rubies take the keyword hash as
746
+ # the positional non_block flag and raise ThreadError immediately.
747
+ def curb_pop_within(queue, seconds)
748
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
749
+ loop do
750
+ begin
751
+ return queue.pop(true)
752
+ rescue ThreadError
753
+ return nil if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
754
+ sleep 0.01
755
+ end
756
+ end
757
+ end
758
+
759
+ # Minimal HTTP/1.1 server so a test controls exactly when and how a response
760
+ # is written. Every request gets its own connection (Connection: close).
761
+ #
762
+ # /fast "fast" immediately
763
+ # /delay/<seconds> "delayed" after sleeping
764
+ # /hang "released" once release_hung is called
765
+ # /stream/<chunks>/<n> <chunks> writes of <n> bytes (see stream_body)
766
+ # /truncate promises 100000 bytes, sends 10, then closes
767
+ # /reset sends part of a body, then aborts with a TCP RST
768
+ class CurbTinyHTTPServer
769
+ attr_reader :port, :requests
770
+
771
+ # Chunk i is filled with one letter so a test can verify reassembly.
772
+ def self.stream_body(chunks, bytes)
773
+ (0...chunks).map { |i| (97 + (i % 26)).chr * bytes }.join
774
+ end
775
+
776
+ def initialize
777
+ @listener = TCPServer.new('127.0.0.1', 0)
778
+ @port = @listener.addr[1]
779
+ @requests = Queue.new
780
+ @release = Queue.new
781
+ @clients = []
782
+ @thread = Thread.new { accept_loop }
783
+ end
784
+
785
+ def url(path)
786
+ "http://127.0.0.1:#{@port}#{path}"
787
+ end
788
+
789
+ # Let pending /hang requests finish.
790
+ def release_hung(count = 64)
791
+ count.times { @release << true }
792
+ end
793
+
794
+ def close
795
+ release_hung
796
+ @listener.close rescue nil
797
+ @thread.kill
798
+ @thread.join(2)
799
+ @clients.each { |t| t.kill; t.join(1) }
800
+ end
801
+
802
+ private
803
+
804
+ def accept_loop
805
+ loop do
806
+ sock = @listener.accept
807
+ @clients << Thread.new(sock) { |s| handle(s) }
808
+ end
809
+ rescue IOError, Errno::EBADF
810
+ nil
811
+ end
812
+
813
+ def handle(sock)
814
+ request_line = sock.gets.to_s
815
+ while (line = sock.gets) && line != "\r\n"; end
816
+ path = request_line.split(' ')[1].to_s
817
+ @requests << path
818
+
819
+ case path
820
+ when %r{\A/delay/([\d.]+)}
821
+ sleep Float($1)
822
+ respond(sock, "delayed")
823
+ when '/hang'
824
+ curb_pop_within(@release, 10)
825
+ respond(sock, "released")
826
+ when %r{\A/stream/(\d+)/(\d+)}
827
+ chunks = Integer($1)
828
+ bytes = Integer($2)
829
+ sock.write("HTTP/1.1 200 OK\r\nContent-Length: #{chunks * bytes}\r\nConnection: close\r\n\r\n")
830
+ chunks.times do |i|
831
+ sock.write((97 + (i % 26)).chr * bytes)
832
+ Thread.pass
833
+ end
834
+ when '/truncate'
835
+ sock.write("HTTP/1.1 200 OK\r\nContent-Length: 100000\r\nConnection: close\r\n\r\n0123456789")
836
+ when '/reset'
837
+ sock.write("HTTP/1.1 200 OK\r\nContent-Length: 100000\r\nConnection: close\r\n\r\n0123456789")
838
+ sock.setsockopt(Socket::Option.linger(true, 0))
839
+ else
840
+ respond(sock, "fast")
841
+ end
842
+ rescue IOError, SystemCallError
843
+ nil
844
+ ensure
845
+ sock.close rescue nil
846
+ end
847
+
848
+ def respond(sock, body)
849
+ sock.write("HTTP/1.1 200 OK\r\nContent-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
850
+ end
851
+ end