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.
@@ -46,11 +46,12 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
46
46
  end
47
47
 
48
48
  def kernel_sleep(duration = nil)
49
- sleep(duration || 0)
49
+ # A bare sleep here would re-enter this hook through the scheduler.
50
+ blocking_io { sleep(duration || 0) }
50
51
  end
51
52
 
52
53
  def block(_blocker, timeout = nil)
53
- sleep(timeout || 0)
54
+ blocking_io { sleep(timeout || 0) }
54
55
  false
55
56
  end
56
57
 
@@ -69,7 +70,7 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
69
70
  if Fiber.respond_to?(:blocking)
70
71
  Fiber.blocking(&block)
71
72
  else
72
- block.call
73
+ Fiber.new(blocking: true, &block).resume
73
74
  end
74
75
  end
75
76
  end
@@ -117,7 +118,7 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
117
118
 
118
119
  duration = Time.now - started
119
120
 
120
- assert duration < THRESHOLD, "Requests did not run concurrently under fiber scheduler (#{duration}s) which exceeds the expected threshold of: #{THRESHOLD} serial time would be about: #{SERIAL_TIME_WOULD_BE_ABOUT}"
121
+ assert_ran_concurrently(duration)
121
122
  assert_equal ITERS, results.size
122
123
  assert_equal ITERS.times.map {200}, results
123
124
  end
@@ -148,7 +149,7 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
148
149
 
149
150
  duration = Time.now - started
150
151
 
151
- assert duration < THRESHOLD, "Requests did not run concurrently under fiber scheduler (#{duration}s) which exceeds the expected threshold of: #{THRESHOLD} serial time would be about: #{SERIAL_TIME_WOULD_BE_ABOUT}"
152
+ assert_ran_concurrently(duration)
152
153
  assert_equal ITERS, results.size
153
154
  assert_equal ITERS.times.map {200}, results
154
155
  end
@@ -215,7 +216,7 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
215
216
  c = Curl::Easy.new(url)
216
217
  c.on_complete { result = c.code }
217
218
  m.add(c)
218
- unless m.respond_to?(:_socket_perform, true)
219
+ unless socket_perform_supported?
219
220
  omit('socket-action perform path is not available in this build')
220
221
  end
221
222
  m.send(:_socket_perform)
@@ -231,6 +232,249 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
231
232
  end
232
233
  end
233
234
 
235
+ # Multi#perform must not fall back to the thread-blocking legacy fdset loop
236
+ # when the calling fiber runs under a scheduler: the waits have to go
237
+ # through the scheduler's io_wait/io_select hooks so sibling fibers run.
238
+ def test_multi_perform_waits_via_scheduler_hooks
239
+ omit('Skipping custom scheduler test on Windows') if WINDOWS
240
+ omit('Fiber scheduler API unavailable on this Ruby') unless fiber_scheduler_supported?
241
+
242
+ url = "http://127.0.0.1:#{@port}/test"
243
+ scheduler = RecordingScheduler.new
244
+ result = nil
245
+
246
+ with_scheduler(scheduler) do
247
+ m = Curl::Multi.new
248
+ unless socket_perform_supported?
249
+ omit('socket-action perform path is not available in this build')
250
+ end
251
+ c = Curl::Easy.new(url)
252
+ c.on_complete { result = c.code }
253
+ m.add(c)
254
+ m.perform
255
+ ensure
256
+ m.close if m
257
+ end
258
+
259
+ assert_equal 200, result
260
+ assert_operator scheduler.io_wait_events.length + scheduler.io_select_calls, :>=, 1,
261
+ 'Multi#perform under a fiber scheduler should wait through the scheduler hooks'
262
+ end
263
+
264
+ def test_multi_perform_propagates_io_wrapper_errors
265
+ omit('Skipping custom scheduler test on Windows') if WINDOWS
266
+ omit('Fiber scheduler API unavailable on this Ruby') unless fiber_scheduler_supported?
267
+ omit('socket-action perform path is not available in this build') unless socket_perform_supported?
268
+
269
+ url = "http://127.0.0.1:#{@port}/test"
270
+ scheduler = RecordingScheduler.new
271
+ wrapper_error = Class.new(StandardError)
272
+ original_for_fd = IO.method(:for_fd)
273
+ redefine_io_for_fd do |*_args|
274
+ raise wrapper_error, 'IO wrapper failed'
275
+ end
276
+
277
+ error = assert_raise(wrapper_error) do
278
+ with_scheduler(scheduler) do
279
+ m = Curl::Multi.new
280
+ m.add(Curl::Easy.new(url))
281
+ m.perform
282
+ ensure
283
+ m.close if m
284
+ end
285
+ end
286
+
287
+ assert_equal 'IO wrapper failed', error.message
288
+ ensure
289
+ redefine_io_for_fd(original_for_fd) if original_for_fd
290
+ end
291
+
292
+ # Probe-style regression tests: all previous concurrency assertions routed N
293
+ # requests through one multi handle, so libcurl provided the parallelism and
294
+ # they passed even when the wait loop blocked the whole thread. Here a
295
+ # sibling fiber ticks every 10ms while a single request is in flight; a
296
+ # blocking wait loop caps the ticks at roughly one per 100ms select chunk,
297
+ # while a scheduler-friendly loop lets most of the ~MIN_S/0.01 ticks happen.
298
+ def test_sibling_fibers_progress_during_multi_perform
299
+ return if skip_no_async
300
+ return if skip_no_socket_perform
301
+
302
+ url = "http://127.0.0.1:#{@port}/test"
303
+ result = nil
304
+
305
+ ticks = probe_sibling_ticks do
306
+ m = Curl::Multi.new
307
+ begin
308
+ c = Curl::Easy.new(url)
309
+ c.on_complete { result = c.code }
310
+ m.add(c)
311
+ m.perform
312
+ ensure
313
+ m.close
314
+ end
315
+ end
316
+
317
+ assert_equal 200, result
318
+ assert_operator ticks, :>=, SIBLING_TICKS_MIN,
319
+ "sibling fiber starved during Multi#perform: #{ticks} ticks in a #{MIN_S}s transfer (~#{SIBLING_TICKS_POSSIBLE} possible)"
320
+ end
321
+
322
+ def test_sibling_fibers_progress_during_easy_perform
323
+ return if skip_no_async
324
+ return if skip_no_socket_perform
325
+
326
+ url = "http://127.0.0.1:#{@port}/test"
327
+ result = nil
328
+
329
+ ticks = probe_sibling_ticks do
330
+ c = Curl::Easy.new(url)
331
+ c.perform
332
+ result = c.code
333
+ end
334
+
335
+ assert_equal 200, result
336
+ assert_operator ticks, :>=, SIBLING_TICKS_MIN,
337
+ "sibling fiber starved during Easy#perform: #{ticks} ticks in a #{MIN_S}s transfer (~#{SIBLING_TICKS_POSSIBLE} possible)"
338
+ end
339
+
340
+ def test_sibling_fibers_progress_during_multi_perform_with_multiple_sockets
341
+ return if skip_no_async
342
+ return if skip_no_socket_perform
343
+
344
+ url = "http://127.0.0.1:#{@port}/test"
345
+ results = []
346
+
347
+ ticks = probe_sibling_ticks do
348
+ m = Curl::Multi.new
349
+ begin
350
+ 2.times do
351
+ c = Curl::Easy.new(url)
352
+ c.on_complete { results << c.code }
353
+ m.add(c)
354
+ end
355
+ m.perform
356
+ ensure
357
+ m.close
358
+ end
359
+ end
360
+
361
+ assert_equal [200, 200], results.sort
362
+ assert_operator ticks, :>=, SIBLING_TICKS_MIN,
363
+ "sibling fiber starved during multi-fd Multi#perform: #{ticks} ticks in a #{MIN_S}s transfer (~#{SIBLING_TICKS_POSSIBLE} possible)"
364
+ end
365
+
366
+ def test_scheduler_timeout_interrupts_single_socket_multi_perform
367
+ return if skip_no_async
368
+ return if skip_no_socket_perform
369
+
370
+ assert_scheduler_timeout_interrupts_multi_perform(1)
371
+ end
372
+
373
+ def test_scheduler_timeout_interrupts_multi_socket_multi_perform
374
+ return if skip_no_async
375
+ return if skip_no_socket_perform
376
+
377
+ assert_scheduler_timeout_interrupts_multi_perform(2)
378
+ end
379
+
380
+ def test_multi_perform_runs_work_added_from_final_idle_yield_under_scheduler
381
+ return if skip_no_async
382
+ return if skip_no_socket_perform
383
+
384
+ url = "http://127.0.0.1:#{@port}/test"
385
+ completions = []
386
+ empty_yields = 0
387
+ queued_added = false
388
+ previous_autoclose = Curl::Multi.autoclose
389
+ Curl::Multi.autoclose = true
390
+
391
+ begin
392
+ async_run do
393
+ m = Curl::Multi.new
394
+ begin
395
+ first = Curl::Easy.new(url)
396
+ first.on_complete { completions << :first }
397
+ m.add(first)
398
+
399
+ m.perform do |performing_multi|
400
+ empty_yields += 1 if performing_multi.requests.empty?
401
+ if !queued_added && empty_yields >= 2
402
+ queued_added = true
403
+ queued = Curl::Easy.new(url)
404
+ queued.on_complete { completions << :queued }
405
+ performing_multi.add(queued)
406
+ end
407
+ end
408
+ ensure
409
+ m.close
410
+ end
411
+ end
412
+
413
+ assert queued_added, 'test should add queued work from the final idle yield'
414
+ assert_equal [:first, :queued], completions
415
+ ensure
416
+ Curl::Multi.autoclose = previous_autoclose
417
+ end
418
+ end
419
+
420
+ # A scheduler that implements io_wait but not the optional io_select hook
421
+ # must neither busy-loop nor fall back to a thread-blocking wait. The broken
422
+ # loop spins without an interrupt checkpoint, so the probe runs in a
423
+ # subprocess guarded by a parent-side SIGKILL fuse.
424
+ def test_socket_perform_progresses_when_scheduler_lacks_io_select
425
+ omit('Skipping custom scheduler test on Windows') if WINDOWS
426
+ omit('Fiber scheduler API unavailable on this Ruby') unless fiber_scheduler_supported?
427
+ unless socket_perform_supported?
428
+ omit('socket-action perform path is not available in this build')
429
+ end
430
+
431
+ script = File.join(File.dirname(__FILE__), 'io_select_less_scheduler_probe.rb')
432
+ out_r, out_w = IO.pipe
433
+ pid = Process.spawn(RbConfig.ruby, '-I', $LIBDIR, '-I', $EXTDIR, script, @port.to_s,
434
+ :out => out_w, :err => out_w)
435
+ out_w.close
436
+
437
+ fuse_seconds = 30
438
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + fuse_seconds
439
+ status = nil
440
+ loop do
441
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
442
+ break if status
443
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
444
+ begin
445
+ Process.kill('KILL', pid)
446
+ rescue Errno::ESRCH
447
+ end
448
+ begin
449
+ Process.waitpid(pid)
450
+ rescue Errno::ECHILD
451
+ end
452
+ flunk "scheduler without io_select busy-looped _socket_perform (no exit within #{fuse_seconds}s; killed)"
453
+ end
454
+ sleep 0.05
455
+ end
456
+
457
+ output = out_r.read
458
+ assert status.success?, "io_select-less scheduler subprocess failed: #{output}"
459
+ match = /^OK ticks=(\d+)$/.match(output)
460
+ assert_not_nil match, "unexpected io_select-less scheduler output: #{output}"
461
+ assert_operator match[1].to_i, :>=, 2,
462
+ "scheduler without io_select did not make sibling-fiber progress: #{output}"
463
+ ensure
464
+ if pid && !status
465
+ begin
466
+ Process.kill('KILL', pid)
467
+ rescue Errno::ESRCH
468
+ end
469
+ begin
470
+ Process.waitpid(pid)
471
+ rescue Errno::ECHILD
472
+ end
473
+ end
474
+ out_r.close if out_r && !out_r.closed?
475
+ out_w.close if out_w && !out_w.closed?
476
+ end
477
+
234
478
  def test_multi_reuse_after_scheduler_perform
235
479
  unless HAS_ASYNC
236
480
  warn 'Skipping fiber scheduler test (Async gem not available)'
@@ -490,6 +734,97 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
490
734
  end
491
735
  end
492
736
 
737
+ # Records unblock calls in place of a real scheduler.
738
+ class UnblockRecorder
739
+ attr_reader :unblocked
740
+
741
+ def initialize
742
+ @unblocked = []
743
+ end
744
+
745
+ def unblock(blocker, _fiber)
746
+ @unblocked << blocker
747
+ end
748
+ end
749
+
750
+ # Enumerating every waiter on every drive-loop pass made release cost
751
+ # O(waiters) per pass; only the queued completions should be visited.
752
+ class NonEnumerableWaiters < Hash
753
+ %i[each each_pair each_value each_key].each do |name|
754
+ define_method(name) { |*| raise 'release_scheduler_waiters must not enumerate all waiters' }
755
+ end
756
+ end
757
+
758
+ def scheduler_test_waiter(scheduler)
759
+ {completed: true, done: false, error: nil, fiber: Fiber.new {}, scheduler: scheduler}
760
+ end
761
+
762
+ def test_release_scheduler_waiters_visits_only_queued_completions
763
+ state = Curl.scheduler_state
764
+ recorder = UnblockRecorder.new
765
+ waiters = NonEnumerableWaiters.new
766
+ 1_000.times { |i| waiters[i] = scheduler_test_waiter(recorder).merge(completed: false) }
767
+ finished = scheduler_test_waiter(recorder)
768
+ waiters[:finished] = finished
769
+ state[:waiters] = waiters
770
+ state[:completed] << [:finished, finished]
771
+
772
+ Curl.release_scheduler_waiters(state)
773
+
774
+ assert_equal [finished], recorder.unblocked
775
+ assert finished[:done]
776
+ assert_empty state[:completed]
777
+ ensure
778
+ cleanup_scheduler_state
779
+ end
780
+
781
+ # A waiter whose perform already returned (it raised) is unregistered and its
782
+ # fiber may be blocked on something else, so a late completion must not
783
+ # unblock it.
784
+ def test_release_scheduler_waiters_skips_unregistered_waiter
785
+ state = Curl.scheduler_state
786
+ recorder = UnblockRecorder.new
787
+ stale = scheduler_test_waiter(recorder)
788
+ state[:completed] << [:stale, stale]
789
+
790
+ Curl.release_scheduler_waiters(state)
791
+
792
+ assert_empty recorder.unblocked
793
+ assert_equal false, stale[:done]
794
+ assert_empty state[:completed]
795
+ ensure
796
+ cleanup_scheduler_state
797
+ end
798
+
799
+ # The transfer whose callback raised the deferred exception receives that
800
+ # error rather than a normal completion; it stays queued so it is completed
801
+ # normally if the exception source changes.
802
+ def test_release_scheduler_waiters_holds_back_deferred_exception_source
803
+ state = Curl.scheduler_state
804
+ recorder = UnblockRecorder.new
805
+ source = scheduler_test_waiter(recorder)
806
+ sibling = scheduler_test_waiter(recorder)
807
+ state[:waiters][:source] = source
808
+ state[:waiters][:sibling] = sibling
809
+ state[:completed] << [:source, source] << [:sibling, sibling]
810
+ state[:multi].instance_variable_set(:@__curb_deferred_exception_source_id, :source)
811
+
812
+ Curl.release_scheduler_waiters(state)
813
+
814
+ assert_equal [sibling], recorder.unblocked
815
+ assert_equal false, source[:done]
816
+ assert_equal [[:source, source]], state[:completed]
817
+
818
+ state[:multi].remove_instance_variable(:@__curb_deferred_exception_source_id)
819
+ Curl.release_scheduler_waiters(state)
820
+
821
+ assert_equal [sibling, source], recorder.unblocked
822
+ assert source[:done]
823
+ assert_empty state[:completed]
824
+ ensure
825
+ cleanup_scheduler_state
826
+ end
827
+
493
828
  def test_drain_scheduler_pending_does_not_drop_work_rejected_during_deferred_abort
494
829
  state = Curl.scheduler_state
495
830
  easy = Curl::Easy.new("http://127.0.0.1:#{@port}/test")
@@ -508,7 +843,79 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
508
843
  cleanup_scheduler_state
509
844
  end
510
845
 
846
+ # Sibling-fiber probe bounds: ~MIN_S/0.01 ticks are possible while the
847
+ # request is in flight. A thread-blocking wait loop yields only one tick per
848
+ # 100ms select chunk (2-3 for MIN_S=0.25); require a comfortably higher
849
+ # count while leaving slack for slow CI hosts.
850
+ SIBLING_TICKS_POSSIBLE = (MIN_S / 0.01).to_i
851
+ SIBLING_TICKS_MIN = 8
852
+
511
853
  private
854
+ def redefine_io_for_fd(callable = nil, &block)
855
+ previous_verbose = $VERBOSE
856
+ $VERBOSE = nil
857
+ if callable
858
+ IO.define_singleton_method(:for_fd, callable)
859
+ else
860
+ IO.define_singleton_method(:for_fd, &block)
861
+ end
862
+ ensure
863
+ $VERBOSE = previous_verbose
864
+ end
865
+
866
+ def assert_scheduler_timeout_interrupts_multi_perform(easy_count)
867
+ url = "http://127.0.0.1:#{@port}/test"
868
+ timeout_error = nil
869
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
870
+
871
+ async_run do |task|
872
+ begin
873
+ task.with_timeout(0.05) do
874
+ m = Curl::Multi.new
875
+ begin
876
+ easy_count.times { m.add(Curl::Easy.new(url)) }
877
+ m.perform
878
+ ensure
879
+ m.close
880
+ end
881
+ end
882
+ rescue Async::TimeoutError => error
883
+ timeout_error = error
884
+ end
885
+ end
886
+
887
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
888
+ assert_kind_of Async::TimeoutError, timeout_error,
889
+ "scheduler timeout was swallowed for #{easy_count} easy handle(s)"
890
+ assert_operator elapsed, :<, MIN_S * 0.8,
891
+ "scheduler cancellation took #{elapsed}s for #{easy_count} easy handle(s)"
892
+ end
893
+
894
+ # Runs the block in one fiber while a sibling fiber ticks every 10ms;
895
+ # returns how many ticks the sibling managed before the block finished.
896
+ def probe_sibling_ticks
897
+ ticks = 0
898
+ done = false
899
+
900
+ async_run do |top|
901
+ ticker = top.async do
902
+ until done
903
+ sleep 0.01
904
+ ticks += 1 unless done
905
+ end
906
+ end
907
+
908
+ top.async do
909
+ yield
910
+ ensure
911
+ done = true
912
+ end.wait
913
+ ticker.wait
914
+ end
915
+
916
+ ticks
917
+ end
918
+
512
919
  def skip_no_async
513
920
  if WINDOWS
514
921
  warn 'Skipping fiber scheduler tests on Windows'
@@ -525,6 +932,26 @@ class TestCurbFiberScheduler < Test::Unit::TestCase
525
932
  false
526
933
  end
527
934
 
935
+ def skip_no_socket_perform
936
+ unless socket_perform_supported?
937
+ warn 'Skipping fiber scheduler test (socket-action perform path unavailable)'
938
+ return true
939
+ end
940
+ false
941
+ end
942
+
943
+ # Wall-clock bounds are not reliable under valgrind, which slows and
944
+ # serializes threads unpredictably; there the requests and their results are
945
+ # still checked under memcheck, only the duration bound is skipped.
946
+ def assert_ran_concurrently(duration)
947
+ return if curb_under_valgrind?
948
+ assert duration < THRESHOLD, "Requests did not run concurrently under fiber scheduler (#{duration}s) which exceeds the expected threshold of: #{THRESHOLD} serial time would be about: #{SERIAL_TIME_WOULD_BE_ABOUT}"
949
+ end
950
+
951
+ def socket_perform_supported?
952
+ Curl::Multi.private_method_defined?(:_socket_perform)
953
+ end
954
+
528
955
  def async_run(&block)
529
956
  # Prefer newer Async.run to avoid deprecated scheduler.async path.
530
957
  if defined?(Async) && Async.respond_to?(:run)
@@ -1,6 +1,23 @@
1
1
  require File.expand_path(File.join(File.dirname(__FILE__), 'helper'))
2
2
 
3
3
  class TestCurbFtpOptions < Test::Unit::TestCase
4
+ SCALAR_AND_STRING_OPTIONS = {
5
+ :ftpport => "-",
6
+ :dirlistonly => true,
7
+ :append => true,
8
+ :ftp_use_eprt => false,
9
+ :ftp_use_epsv => false,
10
+ :ftp_use_pret => true,
11
+ :ftp_create_missing_dirs => true,
12
+ :ftp_response_timeout => 30,
13
+ :ftp_alternative_to_user => "anonymous",
14
+ :ftp_skip_pasv_ip => true,
15
+ :ftpsslauth => 0,
16
+ :ftp_ssl_ccc => 0,
17
+ :ftp_account => "account",
18
+ :ftp_filemethod => 0
19
+ }.freeze
20
+
4
21
  # Ensure FTP-related set(:option, ...) mappings are accepted and do not raise
5
22
  # a TypeError (they used to be unsupported in setopt dispatch).
6
23
  def test_can_set_ftp_listing_related_flags
@@ -15,6 +32,47 @@ class TestCurbFtpOptions < Test::Unit::TestCase
15
32
  end
16
33
  end
17
34
 
35
+ def test_can_set_detected_scalar_and_string_ftp_options
36
+ c = Curl::Easy.new('ftp://example.com/')
37
+
38
+ SCALAR_AND_STRING_OPTIONS.each do |name, value|
39
+ constant = "CURLOPT_#{name.to_s.upcase}"
40
+ next unless Curl.const_defined?(constant)
41
+
42
+ assert_nothing_raised("setting #{name}") { c.set(name, value) }
43
+ end
44
+ end
45
+
46
+ def test_issue_481_ftp_create_missing_dirs_accessor_and_set
47
+ omit('CURLOPT_FTP_CREATE_MISSING_DIRS is not available in this build') unless
48
+ Curl.const_defined?(:CURLOPT_FTP_CREATE_MISSING_DIRS)
49
+
50
+ c = Curl::Easy.new('ftp://example.com/')
51
+
52
+ assert_respond_to(c, :ftp_create_missing_dirs=)
53
+ assert_respond_to(c, :ftp_create_missing_dirs)
54
+ assert_nil(c.ftp_create_missing_dirs)
55
+
56
+ c.ftp_create_missing_dirs = true
57
+ assert_equal(true, c.ftp_create_missing_dirs)
58
+
59
+ assert_equal(1, c.set(:ftp_create_missing_dirs, 1))
60
+ assert_equal(1, c.ftp_create_missing_dirs)
61
+
62
+ retry_mode = if Curl.const_defined?(:CURLFTP_CREATE_DIR_RETRY)
63
+ Curl::CURLFTP_CREATE_DIR_RETRY
64
+ else
65
+ 2
66
+ end
67
+ c.set(:ftp_create_missing_dirs, retry_mode)
68
+ assert_equal(retry_mode, c.ftp_create_missing_dirs)
69
+ assert_equal(retry_mode, c.clone.ftp_create_missing_dirs)
70
+
71
+ settings = c.reset
72
+ assert_equal(retry_mode, settings[:ftp_create_missing_dirs])
73
+ assert_nil(c.ftp_create_missing_dirs)
74
+ end
75
+
18
76
  # Setting ftp_commands remains supported for control-connection commands.
19
77
  def test_can_assign_ftp_commands
20
78
  c = Curl::Easy.new('ftp://example.com/')
@@ -96,6 +96,59 @@ class TestGcCompact < Test::Unit::TestCase
96
96
  end
97
97
  end
98
98
 
99
+ # Regression: after compaction relocates a persistent Curl::Easy and its old
100
+ # heap slot is recycled by another T_DATA object, completion dispatch (which
101
+ # reads the rbce->self back-reference via CURLINFO_PRIVATE) must still
102
+ # resolve to the correct Curl::Easy. Without pinning in curl_easy_mark this
103
+ # raises "TypeError: wrong argument type Curl::Upload (expected Curl::Easy)"
104
+ # on the first round, or defers it onto the multi and raises it from a later
105
+ # request.
106
+ def test_completion_dispatch_survives_compact_and_slot_reuse
107
+ omit('needs GC.verify_compaction_references') unless GC.respond_to?(:verify_compaction_references)
108
+
109
+ # Heap-only reference: holding the easy in a local would pin it to the
110
+ # stack and mask the bug.
111
+ holder = { easy: Curl::Easy.new($TEST_URL) }
112
+ holder[:easy].timeout = 5
113
+ completed = nil
114
+ holder[:easy].on_complete { |e| completed = e }
115
+ holder[:easy].http(:GET)
116
+ assert_same holder[:easy], completed
117
+ expected_body = holder[:easy].body_str
118
+ refute_empty expected_body
119
+
120
+ ruby_version = RUBY_VERSION.split('.').first(2).map(&:to_i)
121
+ heap_expansion = if (ruby_version <=> [3, 2]) >= 0
122
+ { expand_heap: true }
123
+ else
124
+ { double_heap: true }
125
+ end
126
+
127
+ 3.times do
128
+ # Relocate every movable object, then recycle freed slots with other
129
+ # T_DATA instances so a stale back-reference cannot fail soft.
130
+ GC.verify_compaction_references(toward: :empty, **heap_expansion)
131
+ churn = Array.new(50_000) { Curl::Upload.new }
132
+
133
+ completed = nil
134
+ holder[:easy].http(:GET)
135
+
136
+ multi = holder[:easy].multi
137
+ if multi&.instance_variable_defined?(:@__curb_deferred_exception)
138
+ raise multi.instance_variable_get(:@__curb_deferred_exception)
139
+ end
140
+ assert_same holder[:easy], completed
141
+ assert_equal expected_body, holder[:easy].body_str
142
+ churn.clear
143
+ end
144
+ ensure
145
+ begin
146
+ holder[:easy]&.close
147
+ rescue StandardError
148
+ nil
149
+ end
150
+ end
151
+
99
152
  private
100
153
 
101
154
  def run_multi_perform_compact_iteration