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.
- checksums.yaml +4 -4
- data/README.md +71 -14
- data/Rakefile +12 -1
- data/ext/curb.c +26 -4
- data/ext/curb.h +4 -4
- data/ext/curb_easy.c +178 -77
- data/ext/curb_easy.h +1 -0
- data/ext/curb_multi.c +639 -225
- data/ext/curb_multi.h +4 -0
- data/ext/curb_postfield.c +31 -31
- data/ext/extconf.rb +10 -0
- data/lib/curl/easy.rb +18 -9
- data/lib/curl.rb +49 -12
- data/tests/helper.rb +300 -4
- data/tests/io_select_less_scheduler_probe.rb +105 -0
- data/tests/tc_curl_easy.rb +60 -4
- data/tests/tc_curl_easy_cookielist.rb +8 -4
- data/tests/tc_curl_easy_resolve.rb +2 -0
- data/tests/tc_curl_multi.rb +29 -0
- data/tests/tc_curl_multi_poll.rb +223 -0
- data/tests/tc_curl_multi_socket_poller.rb +374 -0
- data/tests/tc_curl_native_coverage.rb +1 -0
- data/tests/tc_curl_postfield.rb +39 -0
- data/tests/tc_fiber_scheduler.rb +433 -6
- data/tests/tc_ftp_options.rb +58 -0
- data/tests/tc_gc_compact.rb +53 -0
- data/tests/tc_ractor.rb +94 -0
- data/tests/tc_test_server_methods.rb +15 -0
- metadata +10 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
(
|
|
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
|
-
|
|
39
|
-
return if
|
|
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
|
-
|
|
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
|
-
|
|
57
|
+
deferred_closes = (state[:deferred_multi_closes] ||= [])
|
|
49
58
|
|
|
50
59
|
if all_threads
|
|
51
|
-
|
|
60
|
+
deferred_closes.shift(deferred_closes.length)
|
|
52
61
|
else
|
|
53
62
|
owner = Thread.current
|
|
54
63
|
remaining = []
|
|
55
64
|
current = []
|
|
56
65
|
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
!!(
|
|
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
|
-
|
|
256
|
+
ractor_local_state[:safety_generation] ||= 0
|
|
244
257
|
end
|
|
245
258
|
|
|
246
259
|
def self.bump_safety_generation!
|
|
247
|
-
|
|
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 :
|
|
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
|
-
|
|
501
|
+
completed = state[:completed]
|
|
502
|
+
return if completed.empty?
|
|
485
503
|
|
|
486
|
-
|
|
487
|
-
|
|
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)
|
|
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][
|
|
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
|
-
|
|
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
|
-
|
|
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
|