wreq 1.2.11 → 1.2.13

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/test/fork_test.rb ADDED
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require "rbconfig"
5
+ require "tempfile"
6
+ require "timeout"
7
+
8
+ class ForkTest < Minitest::Test
9
+ FORK_ERROR_LABELS = %w[
10
+ module_request
11
+ fresh_client_request
12
+ fresh_body_sender_push
13
+ inherited_body_sender_push
14
+ inherited_body_sender_close
15
+ inherited_body_sender_closed
16
+ inherited_client
17
+ inherited_jar
18
+ inherited_cookie_provider
19
+ inherited_response_metadata
20
+ inherited_response
21
+ inherited_response_text
22
+ inherited_response_chunks
23
+ inherited_response_close
24
+ ].freeze
25
+
26
+ def test_fork_error_is_a_runtime_error
27
+ assert_operator Wreq::ForkError, :<, RuntimeError
28
+ end
29
+
30
+ def test_loaded_extension_can_initialize_runtime_after_fork
31
+ skip "fork is not supported on this platform" unless Process.respond_to?(:fork)
32
+
33
+ stdout, stderr, status = run_fork_script("prefork_runtime.rb")
34
+
35
+ assert status.success?, "subprocess failed with #{status.inspect}: #{stderr}"
36
+ assert_equal "ok\n", stdout
37
+ assert_match(/loaded_only=ok/, stderr)
38
+ assert_match(/before_runtime=ok/, stderr)
39
+ assert_match(/parent_after_children=ok/, stderr)
40
+ %w[
41
+ inherited_client_before_runtime
42
+ inherited_sender_before_runtime
43
+ inherited_jar_before_runtime
44
+ inherited_cookie_provider_before_runtime
45
+ ].each do |label|
46
+ assert_match(/#{label}=Wreq::ForkError:.*cannot be used after fork/, stderr)
47
+ end
48
+ refute_match(/\[BUG\]|segmentation fault|panicked/i, stderr)
49
+ end
50
+
51
+ def test_initialized_runtime_is_rejected_after_fork
52
+ skip "fork is not supported on this platform" unless Process.respond_to?(:fork)
53
+
54
+ stdout, stderr, status = run_fork_script("fork_safety.rb")
55
+
56
+ assert status.success?, "subprocess failed with #{status.inspect}: #{stderr}"
57
+ assert_equal "ok\n", stdout
58
+ assert_match(/non_runtime_construction=ok/, stderr)
59
+ assert_match(/inherited_snapshots=ok/, stderr)
60
+ FORK_ERROR_LABELS.each do |label|
61
+ assert_match(/#{label}=Wreq::ForkError:.*cannot be used after fork/, stderr)
62
+ assert_match(/#{label}_retry=Wreq::ForkError:.*cannot be used after fork/, stderr)
63
+ end
64
+ assert_match(/inherited_gc=ok/, stderr)
65
+ refute_match(/\[BUG\]|segmentation fault|panicked/i, stderr)
66
+ end
67
+
68
+ private
69
+
70
+ def run_fork_script(name)
71
+ lib_dir = File.expand_path("../lib", __dir__)
72
+ script = File.expand_path("scripts/#{name}", __dir__)
73
+
74
+ Tempfile.create("wreq-fork-stdout") do |stdout|
75
+ Tempfile.create("wreq-fork-stderr") do |stderr|
76
+ pid = Process.spawn(
77
+ RbConfig.ruby,
78
+ "-I",
79
+ lib_dir,
80
+ script,
81
+ out: stdout,
82
+ err: stderr,
83
+ pgroup: true
84
+ )
85
+ status = Timeout.timeout(30) { Process.wait2(pid).last }
86
+ stdout.rewind
87
+ stderr.rewind
88
+ return [stdout.read, stderr.read, status]
89
+ rescue Timeout::Error
90
+ begin
91
+ Process.kill("KILL", -pid)
92
+ rescue Errno::ESRCH
93
+ nil
94
+ end
95
+
96
+ begin
97
+ Process.wait(pid)
98
+ rescue Errno::ECHILD
99
+ nil
100
+ end
101
+
102
+ flunk "#{name} timed out"
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "timeout"
5
+ require "weakref"
6
+ require "wreq"
7
+
8
+ $stdout.sync = true
9
+ $stderr.sync = true
10
+
11
+ def expect_fork_error(label)
12
+ 2.times do |attempt|
13
+ attempt_label = attempt.zero? ? label : "#{label}_retry"
14
+
15
+ begin
16
+ yield
17
+ rescue Wreq::ForkError => error
18
+ warn "#{attempt_label}=#{error.class}: #{error.message}"
19
+ next
20
+ rescue => error
21
+ abort "#{attempt_label}=unexpected #{error.class}: #{error.message}"
22
+ end
23
+
24
+ abort "#{attempt_label}=missing Wreq::ForkError"
25
+ end
26
+ end
27
+
28
+ server = TCPServer.new("127.0.0.1", 0)
29
+ port = server.addr[1]
30
+ server_pid = fork do
31
+ 3.times do
32
+ ready = IO.select([server], nil, nil, 10)
33
+ exit! 4 unless ready
34
+
35
+ socket = server.accept
36
+ begin
37
+ while (line = socket.gets)
38
+ break if line == "\r\n"
39
+ end
40
+ socket.write("HTTP/1.1 200 OK\r\nX-Fork-Test: ok\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
41
+ ensure
42
+ socket.close
43
+ end
44
+ end
45
+ exit! 0
46
+ ensure
47
+ server.close
48
+ end
49
+ server.close
50
+
51
+ url = "http://127.0.0.1:#{port}/"
52
+
53
+ # Start the server process before Tokio creates worker threads in the parent.
54
+ runtime_probe = Wreq::BodySender.new(1)
55
+ runtime_probe.push("warmup")
56
+
57
+ client = Wreq::Client.new
58
+ abort "parent warm-up failed" unless client.get(url).bytes == "ok"
59
+
60
+ inherited_objects = {
61
+ client: Wreq::Client.new,
62
+ sender: Wreq::BodySender.new,
63
+ response: client.get(url),
64
+ jar: Wreq::Jar.new
65
+ }
66
+ inherited_weak_refs = inherited_objects.values.map { |object| WeakRef.new(object) }
67
+ status_snapshot = inherited_objects[:response].status
68
+ headers_snapshot = inherited_objects[:response].headers
69
+
70
+ guard_pid = fork do
71
+ Timeout.timeout(10) do
72
+ jar = Wreq::Jar.new
73
+ jar.add("child=1; Path=/", url)
74
+ abort "fresh child jar failed" unless jar.get_all.one?
75
+
76
+ Wreq::Client.new(cookie_provider: jar)
77
+ Wreq::BodySender.new
78
+ warn "non_runtime_construction=ok"
79
+
80
+ abort "status snapshot changed" unless status_snapshot.to_i == 200
81
+ abort "headers snapshot changed" unless headers_snapshot["X-Fork-Test"] == "ok"
82
+ warn "inherited_snapshots=ok"
83
+
84
+ expect_fork_error("module_request") { Wreq.get(url) }
85
+ expect_fork_error("fresh_client_request") { Wreq::Client.new.get(url) }
86
+ expect_fork_error("fresh_body_sender_push") { Wreq::BodySender.new.push("chunk") }
87
+ expect_fork_error("inherited_body_sender_push") do
88
+ inherited_objects[:sender].push("chunk")
89
+ end
90
+ expect_fork_error("inherited_body_sender_close") { inherited_objects[:sender].close }
91
+ expect_fork_error("inherited_body_sender_closed") { inherited_objects[:sender].closed? }
92
+ expect_fork_error("inherited_client") { inherited_objects[:client].get(url) }
93
+ expect_fork_error("inherited_jar") { inherited_objects[:jar].get_all }
94
+ expect_fork_error("inherited_cookie_provider") do
95
+ Wreq::Client.new(cookie_provider: inherited_objects[:jar])
96
+ end
97
+ expect_fork_error("inherited_response_metadata") { inherited_objects[:response].status }
98
+ expect_fork_error("inherited_response") { inherited_objects[:response].bytes }
99
+ expect_fork_error("inherited_response_text") { inherited_objects[:response].text }
100
+ expect_fork_error("inherited_response_chunks") { inherited_objects[:response].chunks { nil } }
101
+ expect_fork_error("inherited_response_close") { inherited_objects[:response].close }
102
+ end
103
+ exit! 0
104
+ rescue => error
105
+ warn "guard_checks=unexpected #{error.class}: #{error.message}"
106
+ exit! 2
107
+ end
108
+ _, guard_status = Process.wait2(guard_pid)
109
+ abort "guard checks child failed with #{guard_status.inspect}" unless guard_status.success?
110
+
111
+ # The hash is now the only strong reference to the inherited native objects.
112
+ GC.start(full_mark: true, immediate_sweep: true)
113
+ gc_pid = fork do
114
+ inherited_objects = nil
115
+ 3.times { GC.start(full_mark: true, immediate_sweep: true) }
116
+ alive = inherited_weak_refs.each_index.select do |index|
117
+ inherited_weak_refs[index].weakref_alive?
118
+ end
119
+ abort "inherited objects were not collected: #{alive.join(", ")}" unless alive.empty?
120
+ warn "inherited_gc=ok"
121
+ exit! 0
122
+ rescue => error
123
+ warn "inherited_gc=unexpected #{error.class}: #{error.message}"
124
+ exit! 5
125
+ end
126
+ _, gc_status = Process.wait2(gc_pid)
127
+ abort "inherited GC child failed with #{gc_status.inspect}" unless gc_status.success?
128
+
129
+ abort "parent request after fork failed" unless client.get(url).bytes == "ok"
130
+ _, server_status = Process.wait2(server_pid)
131
+ abort "server failed with #{server_status.inspect}" unless server_status.success?
132
+
133
+ puts "ok"
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "timeout"
5
+ require "wreq"
6
+
7
+ $stdout.sync = true
8
+ $stderr.sync = true
9
+
10
+ def expect_fork_error(label)
11
+ yield
12
+ abort "#{label}=missing Wreq::ForkError"
13
+ rescue Wreq::ForkError => error
14
+ warn "#{label}=#{error.class}: #{error.message}"
15
+ end
16
+
17
+ def run_child(label)
18
+ child_pid = fork do
19
+ Timeout.timeout(10) { yield }
20
+ warn "#{label}=ok"
21
+ exit! 0
22
+ rescue => error
23
+ warn "#{label}=unexpected #{error.class}: #{error.message}"
24
+ exit! 2
25
+ end
26
+
27
+ _, status = Process.wait2(child_pid)
28
+ abort "#{label} child failed with #{status.inspect}" unless status.success?
29
+ end
30
+
31
+ server = TCPServer.new("127.0.0.1", 0)
32
+ url = "http://127.0.0.1:#{server.addr[1]}/"
33
+ server_pid = fork do
34
+ 3.times do
35
+ ready = IO.select([server], nil, nil, 10)
36
+ exit! 4 unless ready
37
+
38
+ socket = server.accept
39
+ begin
40
+ while (line = socket.gets)
41
+ break if line == "\r\n"
42
+ end
43
+ socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
44
+ ensure
45
+ socket.close
46
+ end
47
+ end
48
+ exit! 0
49
+ ensure
50
+ server.close
51
+ end
52
+ server.close
53
+
54
+ # Requiring the extension is the only parent-side Wreq operation before this fork.
55
+ run_child("loaded_only") do
56
+ abort "module request failed" unless Wreq.get(url).bytes == "ok"
57
+ end
58
+
59
+ inherited_client = Wreq::Client.new
60
+ inherited_sender = Wreq::BodySender.new
61
+ inherited_jar = Wreq::Jar.new
62
+
63
+ run_child("before_runtime") do
64
+ expect_fork_error("inherited_client_before_runtime") do
65
+ inherited_client.get(url)
66
+ end
67
+ expect_fork_error("inherited_sender_before_runtime") { inherited_sender.closed? }
68
+ expect_fork_error("inherited_jar_before_runtime") { inherited_jar.get_all }
69
+ expect_fork_error("inherited_cookie_provider_before_runtime") do
70
+ Wreq::Client.new(cookie_provider: inherited_jar)
71
+ end
72
+
73
+ sender = Wreq::BodySender.new
74
+ sender.push("child")
75
+
76
+ jar = Wreq::Jar.new
77
+ jar.add("child=1; Path=/", url)
78
+ abort "child jar failed" unless jar.get_all.one?
79
+
80
+ client = Wreq::Client.new(cookie_provider: jar)
81
+ abort "client request failed" unless client.get(url).bytes == "ok"
82
+ end
83
+
84
+ Timeout.timeout(10) do
85
+ abort "parent client failed" unless inherited_client.get(url).bytes == "ok"
86
+ inherited_sender.push("parent")
87
+ inherited_jar.add("parent=1; Path=/", url)
88
+ abort "parent jar failed" unless inherited_jar.get_all.one?
89
+ end
90
+ warn "parent_after_children=ok"
91
+
92
+ _, server_status = Process.wait2(server_pid)
93
+ abort "server failed with #{server_status.inspect}" unless server_status.success?
94
+
95
+ puts "ok"
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "socket"
5
+ require "timeout"
6
+
7
+ # A small HTTPS server that serves every expected request on one TLS connection.
8
+ module TlsTestServer
9
+ RESPONSE_BODY = "ok"
10
+
11
+ module_function
12
+
13
+ def with_connection(request_count:)
14
+ tcp_server = TCPServer.new("127.0.0.1", 0)
15
+ context, certificate_der = server_context
16
+ ssl_server = OpenSSL::SSL::SSLServer.new(tcp_server, context)
17
+ outcome = Queue.new
18
+ server_thread = Thread.new do
19
+ socket = ssl_server.accept
20
+ request_lines = []
21
+
22
+ request_count.times do |index|
23
+ request_lines << read_request(socket)
24
+ connection = (index == request_count - 1) ? "close" : "keep-alive"
25
+ socket.write(response(connection))
26
+ socket.flush
27
+ end
28
+
29
+ outcome << {connections: 1, requests: request_lines}
30
+ rescue => error
31
+ outcome << error
32
+ ensure
33
+ socket&.close
34
+ end
35
+ server_thread.report_on_exception = false
36
+
37
+ yield "https://127.0.0.1:#{tcp_server.addr[1]}/", certificate_der
38
+
39
+ result = Timeout.timeout(5) { outcome.pop }
40
+ raise result if result.is_a?(StandardError)
41
+
42
+ result
43
+ ensure
44
+ tcp_server&.close
45
+ server_thread&.join(5)
46
+ if server_thread&.alive?
47
+ server_thread.kill
48
+ server_thread.join
49
+ end
50
+ end
51
+
52
+ def read_request(socket)
53
+ request_line = socket.gets
54
+ raise EOFError, "client closed before sending a request" unless request_line
55
+
56
+ loop do
57
+ line = socket.gets
58
+ raise EOFError, "client closed while sending headers" unless line
59
+ break if line == "\r\n"
60
+ end
61
+
62
+ request_line
63
+ end
64
+ private_class_method :read_request
65
+
66
+ def response(connection)
67
+ [
68
+ "HTTP/1.1 200 OK",
69
+ "Content-Length: #{RESPONSE_BODY.bytesize}",
70
+ "Connection: #{connection}",
71
+ "",
72
+ RESPONSE_BODY
73
+ ].join("\r\n")
74
+ end
75
+ private_class_method :response
76
+
77
+ def server_context
78
+ key = OpenSSL::PKey::RSA.new(2048)
79
+ certificate = OpenSSL::X509::Certificate.new
80
+ certificate.version = 2
81
+ certificate.serial = 1
82
+ certificate.subject = certificate.issuer = OpenSSL::X509::Name.parse("/CN=127.0.0.1")
83
+ certificate.public_key = key.public_key
84
+ certificate.not_before = Time.now - 60
85
+ certificate.not_after = Time.now + 3600
86
+ certificate.sign(key, OpenSSL::Digest.new("SHA256"))
87
+
88
+ context = OpenSSL::SSL::SSLContext.new.tap do |ssl_context|
89
+ ssl_context.cert = certificate
90
+ ssl_context.key = key
91
+ end
92
+ [context, certificate.to_der]
93
+ end
94
+ private_class_method :server_context
95
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "test_helper"
4
+ require_relative "support/tls_server"
5
+
6
+ class TlsInfoTest < Minitest::Test
7
+ HTTPBIN_HTTP_URL = ENV.fetch("HTTPBIN_HTTP_URL", HTTPBIN_URL.sub(/\Ahttps:/, "http:"))
8
+
9
+ def test_tls_info_is_nil_when_disabled_or_request_is_plain_http
10
+ default_response = Wreq::Client.new.get("#{HTTPBIN_URL}/get")
11
+ plain_response = Wreq::Client.new(tls_info: true).get("#{HTTPBIN_HTTP_URL}/get")
12
+
13
+ assert_nil default_response.tls_info
14
+ assert_nil plain_response.tls_info
15
+ end
16
+
17
+ def test_certificate_data_survives_body_lifecycle_on_a_reused_connection
18
+ fixture = TlsTestServer.with_connection(request_count: 2) do |base_url, certificate_der|
19
+ client = Wreq::Client.new(
20
+ tls_info: true,
21
+ verify: false,
22
+ http1_only: true,
23
+ no_proxy: true,
24
+ timeout: 5
25
+ )
26
+
27
+ read_response = client.get("#{base_url}read")
28
+ assert_equal "ok", read_response.text
29
+ read_tls = read_response.tls_info
30
+
31
+ closed_response = client.get("#{base_url}close")
32
+ closed_response.close
33
+ closed_tls = closed_response.tls_info
34
+
35
+ assert_instance_of Wreq::TlsInfo, read_tls
36
+ certificate = read_tls.peer_certificate
37
+ chain = read_tls.peer_certificate_chain
38
+ assert_equal certificate_der, certificate
39
+ assert_equal Encoding::BINARY, certificate.encoding
40
+ assert_equal [certificate_der], chain
41
+ assert_equal Encoding::BINARY, chain.first.encoding
42
+ assert_predicate chain, :frozen?
43
+ assert_equal(
44
+ "#<Wreq::TlsInfo peer_certificate=#{certificate_der.bytesize}B peer_certificate_chain=1>",
45
+ read_tls.inspect
46
+ )
47
+ assert_empty Wreq::TlsInfo.instance_methods(false) & %i[to_h to_s]
48
+
49
+ certificate.clear
50
+ assert_equal certificate_der, read_tls.peer_certificate
51
+ assert_equal certificate_der, closed_tls.peer_certificate
52
+ end
53
+
54
+ assert_equal(
55
+ {connections: 1, requests: ["GET /read HTTP/1.1\r\n", "GET /close HTTP/1.1\r\n"]},
56
+ fixture
57
+ )
58
+ end
59
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wreq
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.11
4
+ version: 1.2.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - SearchApi
@@ -80,6 +80,8 @@ files:
80
80
  - crates/wreq-util/tests/emulate_safari.rs
81
81
  - crates/wreq-util/tests/support/mod.rs
82
82
  - crates/wreq-util/tests/support/server.rs
83
+ - docs/fork-safety.md
84
+ - docs/interrupt-handling.md
83
85
  - docs/windows-gnu-tokio-crash.md
84
86
  - examples/body.rb
85
87
  - examples/client.rb
@@ -90,6 +92,7 @@ files:
90
92
  - examples/send_stream.rb
91
93
  - examples/stream.rb
92
94
  - examples/thread_interrupt.rb
95
+ - examples/tls_info.rb
93
96
  - extconf.rb
94
97
  - lib/wreq.rb
95
98
  - lib/wreq_ruby/body.rb
@@ -100,6 +103,7 @@ files:
100
103
  - lib/wreq_ruby/header.rb
101
104
  - lib/wreq_ruby/http.rb
102
105
  - lib/wreq_ruby/response.rb
106
+ - lib/wreq_ruby/tls.rb
103
107
  - script/build_platform_gem.rb
104
108
  - script/rust_env.rb
105
109
  - src/arch.rs
@@ -142,12 +146,14 @@ files:
142
146
  - src/serde/ser/struct_variant_serializer.rs
143
147
  - src/serde/ser/tuple_variant_serializer.rs
144
148
  - src/serde/tests.rs
149
+ - src/tls.rs
145
150
  - test/body_sender_test.rb
146
151
  - test/client_cookie_test.rb
147
152
  - test/client_test.rb
148
153
  - test/cookie_test.rb
149
154
  - test/emulation_test.rb
150
155
  - test/error_handling_test.rb
156
+ - test/fork_test.rb
151
157
  - test/header_test.rb
152
158
  - test/inspect_test.rb
153
159
  - test/json_precision_test.rb
@@ -157,8 +163,12 @@ files:
157
163
  - test/request_parameters_test.rb
158
164
  - test/request_test.rb
159
165
  - test/response_test.rb
166
+ - test/scripts/fork_safety.rb
167
+ - test/scripts/prefork_runtime.rb
160
168
  - test/stream_test.rb
169
+ - test/support/tls_server.rb
161
170
  - test/test_helper.rb
171
+ - test/tls_info_test.rb
162
172
  - test/value_semantics_test.rb
163
173
  - wreq.gemspec
164
174
  homepage: https://github.com/SearchApi/wreq-ruby