wreq 1.2.11-aarch64-linux → 1.2.12-aarch64-linux
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/docs/fork-safety.md +34 -0
- data/docs/interrupt-handling.md +133 -0
- data/examples/tls_info.rb +27 -0
- data/lib/wreq.rb +13 -0
- data/lib/wreq_ruby/3.3/wreq_ruby.so +0 -0
- data/lib/wreq_ruby/3.4/wreq_ruby.so +0 -0
- data/lib/wreq_ruby/4.0/wreq_ruby.so +0 -0
- data/lib/wreq_ruby/body.rb +6 -0
- data/lib/wreq_ruby/client.rb +19 -0
- data/lib/wreq_ruby/error.rb +13 -0
- data/lib/wreq_ruby/response.rb +28 -0
- data/lib/wreq_ruby/tls.rb +73 -0
- data/test/fork_test.rb +82 -0
- data/test/scripts/fork_safety.rb +110 -0
- data/test/support/tls_server.rb +95 -0
- data/test/tls_info_test.rb +59 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 77c664031054cda90dc0a51bb1eb1489bd140ece7acf2016be6d316faca11798
|
|
4
|
+
data.tar.gz: 41669054ee2c4d3abd8ec8fbcc162a8b10d94261b8db26ca0d43352891a60fda
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9c1a1bed05c48b912a4221c16bcf095bf4ed21e064d00a8add61c67fa23376f5217cefb3c47b7546810f85082d8e60e60293aa6b115c29a4e0c1adcc15b03b70
|
|
7
|
+
data.tar.gz: 80deb6d3d1e748165bf0277aa92b568ac55dc02d8355ef25dd6c793a8610c6c298dc98c8e241628f240126cb8aa2a7360c3ebbbb0a79013e4241024a550eb9ee
|
data/docs/fork-safety.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Fork safety
|
|
2
|
+
|
|
3
|
+
## Why inherited clients are rejected
|
|
4
|
+
|
|
5
|
+
wreq-ruby uses a process-wide Tokio runtime and connection pool. `fork` copies
|
|
6
|
+
the parent's memory, but only the thread that called `fork` continues in the
|
|
7
|
+
child. Tokio's worker threads are gone, and its inherited tasks, locks, and
|
|
8
|
+
connections are not safe to reuse.
|
|
9
|
+
|
|
10
|
+
If the parent has already loaded wreq-ruby, native HTTP operations in the child
|
|
11
|
+
raise `Wreq::ForkError`. This applies to new and existing clients, module
|
|
12
|
+
request methods, streaming request bodies, and response methods backed by native
|
|
13
|
+
state. Retrying the operation in the same child raises the same error. Read-only
|
|
14
|
+
response metadata such as status, headers, and captured TLS information remains
|
|
15
|
+
available.
|
|
16
|
+
|
|
17
|
+
The parent can continue using its clients. When inherited Ruby objects are
|
|
18
|
+
collected in the child, their native runtime state is left for the operating
|
|
19
|
+
system to reclaim when the process exits.
|
|
20
|
+
|
|
21
|
+
## HTTP work in forked children is unsupported
|
|
22
|
+
|
|
23
|
+
A process created with `fork` must not start or continue HTTP work through
|
|
24
|
+
wreq-ruby, even when it first loads the extension after the fork. If the parent
|
|
25
|
+
loaded wreq-ruby, native HTTP operations in the child raise `Wreq::ForkError`.
|
|
26
|
+
|
|
27
|
+
When the extension was not present in the parent, no wreq-ruby state or fork
|
|
28
|
+
marker reaches the child. The extension cannot reliably distinguish that child
|
|
29
|
+
from a newly started process, so this unsupported path cannot guarantee a Ruby
|
|
30
|
+
error and may fail inside platform libraries.
|
|
31
|
+
|
|
32
|
+
Prefork servers should use an `exec`- or spawn-based worker model when workers
|
|
33
|
+
need wreq-ruby. Requiring the extension again does not reset inherited runtime
|
|
34
|
+
state, and there is no `after_fork!` hook.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Interrupt handling policy
|
|
2
|
+
|
|
3
|
+
wreq-ruby must not construct or raise Ruby's built-in `Interrupt` to report a
|
|
4
|
+
request cancellation. This rule applies to the Rust extension and to Ruby
|
|
5
|
+
wrappers in this repository. A pull request that turns a wreq-owned
|
|
6
|
+
cancellation into the built-in class must not be merged.
|
|
7
|
+
|
|
8
|
+
Represent native cancellation as a Rust value until the Ruby-owned calling
|
|
9
|
+
thread has reacquired the GVL. Then map a wreq-owned request cancellation to
|
|
10
|
+
`Wreq::InterruptError`:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
Wreq::InterruptError < Interrupt
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Keep this class outside `StandardError`. A broad transport rescue such as
|
|
17
|
+
`rescue StandardError` must not swallow an interruption.
|
|
18
|
+
|
|
19
|
+
## Why `Interrupt` is reserved
|
|
20
|
+
|
|
21
|
+
Ruby documents `Interrupt` as the exception raised for an interrupt signal,
|
|
22
|
+
usually when the user presses Control-C. Its hierarchy is:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
Exception
|
|
26
|
+
└── SignalException
|
|
27
|
+
└── Interrupt
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`Interrupt` is not a `StandardError`. Ruby's default `rescue` catches
|
|
31
|
+
`StandardError`, so it does not catch `Interrupt` or `Wreq::InterruptError`.
|
|
32
|
+
Code that explicitly uses `rescue Interrupt` catches both because
|
|
33
|
+
`Wreq::InterruptError` is a subclass.
|
|
34
|
+
|
|
35
|
+
The exact built-in class therefore carries Ruby-level control-flow meaning. If
|
|
36
|
+
wreq creates that class for its own cancellation, callers cannot tell whether
|
|
37
|
+
Ruby delivered an interrupt or the HTTP library cancelled a request. A
|
|
38
|
+
library-specific subclass preserves that distinction while keeping the
|
|
39
|
+
interruption outside ordinary transport errors.
|
|
40
|
+
|
|
41
|
+
## Required behavior
|
|
42
|
+
|
|
43
|
+
| Event | wreq-ruby behavior |
|
|
44
|
+
| --- | --- |
|
|
45
|
+
| Ruby raises its built-in `Interrupt`, including an exception supplied through `Thread#raise` | Propagate the original exception. Do not replace or wrap it. |
|
|
46
|
+
| `Thread#kill`, `Thread#terminate`, or `Thread#exit` stops a thread | Let Ruby perform the fatal thread termination. The native unblock callback may request cancellation, but wreq must not translate the event into `Interrupt`. |
|
|
47
|
+
| wreq's native cancellation path finishes without a pending Ruby exception | Raise `Wreq::InterruptError`. |
|
|
48
|
+
| A connection, timeout, protocol, or other transport operation fails | Raise the matching wreq transport error under `StandardError`. |
|
|
49
|
+
|
|
50
|
+
Ruby's implementation also makes an important distinction here.
|
|
51
|
+
`Thread#raise` queues the exception chosen by the caller. `Thread#kill` queues
|
|
52
|
+
Ruby's internal fatal thread-kill event instead of an `Interrupt` object, and
|
|
53
|
+
its termination is asynchronous. Once a no-GVL callback returns, Ruby handles
|
|
54
|
+
that fatal event after reacquiring the GVL and before the native call can return
|
|
55
|
+
normally to wreq's error mapper.
|
|
56
|
+
|
|
57
|
+
## Native no-GVL boundary
|
|
58
|
+
|
|
59
|
+
There are two separate rules at this boundary:
|
|
60
|
+
|
|
61
|
+
1. A Tokio worker, other Rust background thread, no-GVL callback, or UBF must
|
|
62
|
+
not construct or raise any Ruby exception.
|
|
63
|
+
2. Rust code running on the Ruby-owned calling thread with the GVL may construct
|
|
64
|
+
Ruby exceptions, but it must not turn a wreq-owned cancellation into Ruby's
|
|
65
|
+
built-in `Interrupt`.
|
|
66
|
+
|
|
67
|
+
Requests run through `rb_thread_call_without_gvl`. Ruby's C API documents this
|
|
68
|
+
sequence:
|
|
69
|
+
|
|
70
|
+
1. Handle pending interrupts.
|
|
71
|
+
2. Release the GVL.
|
|
72
|
+
3. Run the native callback.
|
|
73
|
+
4. Reacquire the GVL.
|
|
74
|
+
5. Handle interrupts received while the callback was running.
|
|
75
|
+
|
|
76
|
+
Ruby may call the unblock function, or UBF, when another thread interacts with
|
|
77
|
+
the blocked thread. The UBF is a request to stop the native operation. It does
|
|
78
|
+
not identify which Ruby exception, if any, is pending.
|
|
79
|
+
|
|
80
|
+
The UBF in [`src/gvl.rs`](../src/gvl.rs) must only signal cancellation. It must
|
|
81
|
+
not call Ruby APIs or raise an exception while the GVL is released. The request
|
|
82
|
+
future returns its result as a Rust value. Only after the no-GVL call returns
|
|
83
|
+
to the Ruby-owned thread with the GVL may [`src/rt.rs`](../src/rt.rs) map a
|
|
84
|
+
wreq-owned cancellation to the `Wreq::InterruptError` defined in
|
|
85
|
+
[`src/error.rs`](../src/error.rs).
|
|
86
|
+
|
|
87
|
+
Keep this conversion centralized in `rt::try_block_on`. Request, response, and
|
|
88
|
+
body operations may call `try_block_on`, but they must not construct their own
|
|
89
|
+
Ruby cancellation exception.
|
|
90
|
+
|
|
91
|
+
These forms are forbidden for wreq-owned cancellation:
|
|
92
|
+
|
|
93
|
+
```rust
|
|
94
|
+
MagnusError::new(ruby.exception_interrupt(), "request interrupted")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
raise Interrupt, "request interrupted"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Using `exception_interrupt` as the parent when defining
|
|
102
|
+
`Wreq::InterruptError` is still required. Using it as the class passed to
|
|
103
|
+
`MagnusError::new` is not.
|
|
104
|
+
|
|
105
|
+
## Review checklist
|
|
106
|
+
|
|
107
|
+
- Reject direct construction or raising of Ruby's built-in `Interrupt` for a
|
|
108
|
+
wreq-owned cancellation.
|
|
109
|
+
- Keep `Wreq::InterruptError` as a direct subclass of `Interrupt`.
|
|
110
|
+
- Keep Ruby API calls and exception construction out of the no-GVL callback
|
|
111
|
+
and UBF.
|
|
112
|
+
- Preserve an exception supplied by Ruby through `Thread#raise`.
|
|
113
|
+
- Do not turn `Thread#kill`, `Thread#terminate`, or `Thread#exit` into a new
|
|
114
|
+
exception.
|
|
115
|
+
- Test the real cancellation path, the exception hierarchy, and the
|
|
116
|
+
`StandardError` boundary when changing this code.
|
|
117
|
+
|
|
118
|
+
## Ruby references
|
|
119
|
+
|
|
120
|
+
- [Ruby `Interrupt`](https://docs.ruby-lang.org/en/3.4/Interrupt.html) explains
|
|
121
|
+
that the class represents an interrupt signal, usually Control-C, and
|
|
122
|
+
inherits from `SignalException`.
|
|
123
|
+
- [Ruby's built-in exception hierarchy](https://docs.ruby-lang.org/en/4.0/Exception.html#class-Exception-label-Built-In+Exception+Class+Hierarchy)
|
|
124
|
+
shows that `SignalException` and `StandardError` are separate branches.
|
|
125
|
+
- [`Thread#raise`](https://docs.ruby-lang.org/en/4.0/Thread.html#method-i-raise)
|
|
126
|
+
raises the caller-supplied exception in another thread.
|
|
127
|
+
- [`Thread#kill`](https://docs.ruby-lang.org/en/4.0/Thread.html#method-i-kill)
|
|
128
|
+
documents asynchronous termination and its `terminate` and `exit` aliases.
|
|
129
|
+
- [`rb_thread_call_without_gvl`](https://docs.ruby-lang.org/capi/en/master/d6/dfb/include_2ruby_2thread_8h.html)
|
|
130
|
+
documents interrupt checks, GVL reacquisition, UBF cancellation, and the
|
|
131
|
+
restriction on Ruby API calls from no-GVL callbacks.
|
|
132
|
+
- [Issue #111](https://github.com/SearchApi/wreq-ruby/issues/111) contains the
|
|
133
|
+
original error-hierarchy discussion.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "openssl"
|
|
5
|
+
require_relative "../lib/wreq"
|
|
6
|
+
|
|
7
|
+
url = ARGV.fetch(0, "https://example.com")
|
|
8
|
+
client = Wreq::Client.new(tls_info: true)
|
|
9
|
+
response = client.get(url)
|
|
10
|
+
tls_info = response.tls_info
|
|
11
|
+
response.close
|
|
12
|
+
|
|
13
|
+
abort "TLS information is unavailable for #{url}" unless tls_info
|
|
14
|
+
|
|
15
|
+
p tls_info
|
|
16
|
+
|
|
17
|
+
if (der = tls_info.peer_certificate)
|
|
18
|
+
certificate = OpenSSL::X509::Certificate.new(der)
|
|
19
|
+
puts "Subject: #{certificate.subject}"
|
|
20
|
+
puts "Issuer: #{certificate.issuer}"
|
|
21
|
+
puts "Valid from: #{certificate.not_before}"
|
|
22
|
+
puts "Valid until: #{certificate.not_after}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
chain = tls_info.peer_certificate_chain
|
|
26
|
+
chain_size = chain ? chain.length : "unavailable"
|
|
27
|
+
puts "Certificate chain: #{chain_size}"
|
data/lib/wreq.rb
CHANGED
|
@@ -12,6 +12,7 @@ require_relative "wreq_ruby/http"
|
|
|
12
12
|
require_relative "wreq_ruby/emulate"
|
|
13
13
|
require_relative "wreq_ruby/client"
|
|
14
14
|
require_relative "wreq_ruby/response"
|
|
15
|
+
require_relative "wreq_ruby/tls"
|
|
15
16
|
require_relative "wreq_ruby/body"
|
|
16
17
|
require_relative "wreq_ruby/header"
|
|
17
18
|
require_relative "wreq_ruby/error"
|
|
@@ -28,6 +29,9 @@ unless defined?(Wreq)
|
|
|
28
29
|
# raise ArgumentError. Known values retain the error class from their Ruby
|
|
29
30
|
# or native conversion, such as TypeError or Wreq::BuilderError. Validation
|
|
30
31
|
# finishes before network I/O.
|
|
32
|
+
#
|
|
33
|
+
# If a child process inherits wreq-ruby from its parent, requests raise
|
|
34
|
+
# Wreq::ForkError. Require wreq after the worker has been forked.
|
|
31
35
|
|
|
32
36
|
# Send an HTTP request.
|
|
33
37
|
#
|
|
@@ -60,6 +64,7 @@ unless defined?(Wreq)
|
|
|
60
64
|
# @return [Wreq::Response] HTTP response
|
|
61
65
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
62
66
|
# value cannot be converted, validated, or built
|
|
67
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
63
68
|
def self.request(method, url, **options)
|
|
64
69
|
end
|
|
65
70
|
|
|
@@ -93,6 +98,7 @@ unless defined?(Wreq)
|
|
|
93
98
|
# @return [Wreq::Response] HTTP response
|
|
94
99
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
95
100
|
# value cannot be converted, validated, or built
|
|
101
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
96
102
|
def self.get(url, **options)
|
|
97
103
|
end
|
|
98
104
|
|
|
@@ -126,6 +132,7 @@ unless defined?(Wreq)
|
|
|
126
132
|
# @return [Wreq::Response] HTTP response
|
|
127
133
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
128
134
|
# value cannot be converted, validated, or built
|
|
135
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
129
136
|
def self.head(url, **options)
|
|
130
137
|
end
|
|
131
138
|
|
|
@@ -159,6 +166,7 @@ unless defined?(Wreq)
|
|
|
159
166
|
# @return [Wreq::Response] HTTP response
|
|
160
167
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
161
168
|
# value cannot be converted, validated, or built
|
|
169
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
162
170
|
def self.post(url, **options)
|
|
163
171
|
end
|
|
164
172
|
|
|
@@ -192,6 +200,7 @@ unless defined?(Wreq)
|
|
|
192
200
|
# @return [Wreq::Response] HTTP response
|
|
193
201
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
194
202
|
# value cannot be converted, validated, or built
|
|
203
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
195
204
|
def self.put(url, **options)
|
|
196
205
|
end
|
|
197
206
|
|
|
@@ -225,6 +234,7 @@ unless defined?(Wreq)
|
|
|
225
234
|
# @return [Wreq::Response] HTTP response
|
|
226
235
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
227
236
|
# value cannot be converted, validated, or built
|
|
237
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
228
238
|
def self.delete(url, **options)
|
|
229
239
|
end
|
|
230
240
|
|
|
@@ -258,6 +268,7 @@ unless defined?(Wreq)
|
|
|
258
268
|
# @return [Wreq::Response] HTTP response
|
|
259
269
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
260
270
|
# value cannot be converted, validated, or built
|
|
271
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
261
272
|
def self.options(url, **options)
|
|
262
273
|
end
|
|
263
274
|
|
|
@@ -291,6 +302,7 @@ unless defined?(Wreq)
|
|
|
291
302
|
# @return [Wreq::Response] HTTP response
|
|
292
303
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
293
304
|
# value cannot be converted, validated, or built
|
|
305
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
294
306
|
def self.trace(url, **options)
|
|
295
307
|
end
|
|
296
308
|
|
|
@@ -324,6 +336,7 @@ unless defined?(Wreq)
|
|
|
324
336
|
# @return [Wreq::Response] HTTP response
|
|
325
337
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
326
338
|
# value cannot be converted, validated, or built
|
|
339
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
327
340
|
def self.patch(url, **options)
|
|
328
341
|
end
|
|
329
342
|
end
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
data/lib/wreq_ruby/body.rb
CHANGED
|
@@ -17,6 +17,8 @@ unless defined?(Wreq)
|
|
|
17
17
|
#
|
|
18
18
|
# A sender can be attached to one request. Closing it prevents further writes but
|
|
19
19
|
# retains queued chunks so a request attached afterward can still drain them.
|
|
20
|
+
# Creating or using a sender raises Wreq::ForkError if the child inherited
|
|
21
|
+
# wreq-ruby from its parent.
|
|
20
22
|
class BodySender
|
|
21
23
|
# Create a bounded request-body sender.
|
|
22
24
|
#
|
|
@@ -25,6 +27,7 @@ unless defined?(Wreq)
|
|
|
25
27
|
# @return [Wreq::BodySender] A streaming request body sender
|
|
26
28
|
# @raise [ArgumentError] if capacity is zero, negative, or too large
|
|
27
29
|
# @raise [TypeError] if capacity is not an Integer
|
|
30
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
28
31
|
def self.new(capacity = 8)
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -33,6 +36,7 @@ unless defined?(Wreq)
|
|
|
33
36
|
# @param data [String] binary chunk
|
|
34
37
|
# @return [nil]
|
|
35
38
|
# @raise [IOError] if the sender or receiving side is closed
|
|
39
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
36
40
|
def push(data)
|
|
37
41
|
end
|
|
38
42
|
|
|
@@ -41,6 +45,7 @@ unless defined?(Wreq)
|
|
|
41
45
|
# This operation is idempotent.
|
|
42
46
|
#
|
|
43
47
|
# @return [nil]
|
|
48
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
44
49
|
def close
|
|
45
50
|
end
|
|
46
51
|
|
|
@@ -50,6 +55,7 @@ unless defined?(Wreq)
|
|
|
50
55
|
# the receiving side.
|
|
51
56
|
#
|
|
52
57
|
# @return [Boolean]
|
|
58
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
53
59
|
def closed?
|
|
54
60
|
end
|
|
55
61
|
end
|
data/lib/wreq_ruby/client.rb
CHANGED
|
@@ -17,6 +17,10 @@ unless defined?(Wreq)
|
|
|
17
17
|
# native conversion, such as TypeError or Wreq::BuilderError. Request
|
|
18
18
|
# validation finishes before network I/O.
|
|
19
19
|
#
|
|
20
|
+
# A child process cannot create or use a client if it inherited wreq-ruby
|
|
21
|
+
# from its parent. These calls raise Wreq::ForkError before accessing the
|
|
22
|
+
# native runtime. Require wreq after the worker has been forked.
|
|
23
|
+
#
|
|
20
24
|
# @example Basic usage
|
|
21
25
|
# client = Wreq::Client.new
|
|
22
26
|
# # Use client for HTTP requests
|
|
@@ -130,6 +134,11 @@ unless defined?(Wreq)
|
|
|
130
134
|
# including self-signed or expired ones. Should only be disabled
|
|
131
135
|
# for testing purposes.
|
|
132
136
|
#
|
|
137
|
+
# @param tls_info [Boolean, nil] Retain peer certificate data for HTTPS
|
|
138
|
+
# responses. When true, {Wreq::Response#tls_info} may return a
|
|
139
|
+
# {Wreq::TlsInfo} object. Disabled by default because retaining
|
|
140
|
+
# certificate data uses additional memory.
|
|
141
|
+
#
|
|
133
142
|
# @param no_proxy [Boolean, nil] Disable use of any configured proxy
|
|
134
143
|
# for this client, even if proxy settings are detected from the
|
|
135
144
|
# environment.
|
|
@@ -165,6 +174,7 @@ unless defined?(Wreq)
|
|
|
165
174
|
# value cannot be converted or validated.
|
|
166
175
|
# @raise [Wreq::BuilderError, Wreq::TlsError] if the native client cannot
|
|
167
176
|
# be initialized.
|
|
177
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
168
178
|
#
|
|
169
179
|
# @example Minimal client
|
|
170
180
|
# client = Wreq::Client.new
|
|
@@ -280,6 +290,7 @@ unless defined?(Wreq)
|
|
|
280
290
|
# or unavailable on the current platform
|
|
281
291
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
282
292
|
# value cannot be converted, validated, or built
|
|
293
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
283
294
|
def request(method, url, **options)
|
|
284
295
|
end
|
|
285
296
|
|
|
@@ -313,6 +324,7 @@ unless defined?(Wreq)
|
|
|
313
324
|
# @return [Wreq::Response] HTTP response
|
|
314
325
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
315
326
|
# value cannot be converted, validated, or built
|
|
327
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
316
328
|
def get(url, **options)
|
|
317
329
|
end
|
|
318
330
|
|
|
@@ -346,6 +358,7 @@ unless defined?(Wreq)
|
|
|
346
358
|
# @return [Wreq::Response] HTTP response
|
|
347
359
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
348
360
|
# value cannot be converted, validated, or built
|
|
361
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
349
362
|
def head(url, **options)
|
|
350
363
|
end
|
|
351
364
|
|
|
@@ -379,6 +392,7 @@ unless defined?(Wreq)
|
|
|
379
392
|
# @return [Wreq::Response] HTTP response
|
|
380
393
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
381
394
|
# value cannot be converted, validated, or built
|
|
395
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
382
396
|
def post(url, **options)
|
|
383
397
|
end
|
|
384
398
|
|
|
@@ -412,6 +426,7 @@ unless defined?(Wreq)
|
|
|
412
426
|
# @return [Wreq::Response] HTTP response
|
|
413
427
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
414
428
|
# value cannot be converted, validated, or built
|
|
429
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
415
430
|
def put(url, **options)
|
|
416
431
|
end
|
|
417
432
|
|
|
@@ -445,6 +460,7 @@ unless defined?(Wreq)
|
|
|
445
460
|
# @return [Wreq::Response] HTTP response
|
|
446
461
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
447
462
|
# value cannot be converted, validated, or built
|
|
463
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
448
464
|
def delete(url, **options)
|
|
449
465
|
end
|
|
450
466
|
|
|
@@ -478,6 +494,7 @@ unless defined?(Wreq)
|
|
|
478
494
|
# @return [Wreq::Response] HTTP response
|
|
479
495
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
480
496
|
# value cannot be converted, validated, or built
|
|
497
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
481
498
|
def options(url, **options)
|
|
482
499
|
end
|
|
483
500
|
|
|
@@ -511,6 +528,7 @@ unless defined?(Wreq)
|
|
|
511
528
|
# @return [Wreq::Response] HTTP response
|
|
512
529
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
513
530
|
# value cannot be converted, validated, or built
|
|
531
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
514
532
|
def trace(url, **options)
|
|
515
533
|
end
|
|
516
534
|
|
|
@@ -544,6 +562,7 @@ unless defined?(Wreq)
|
|
|
544
562
|
# @return [Wreq::Response] HTTP response
|
|
545
563
|
# @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
|
|
546
564
|
# value cannot be converted, validated, or built
|
|
565
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
547
566
|
def patch(url, **options)
|
|
548
567
|
end
|
|
549
568
|
end
|
data/lib/wreq_ruby/error.rb
CHANGED
|
@@ -11,6 +11,19 @@ unless defined?(Wreq)
|
|
|
11
11
|
# Memory allocation failed.
|
|
12
12
|
class MemoryError < StandardError; end
|
|
13
13
|
|
|
14
|
+
# The child process inherited wreq-ruby from its parent.
|
|
15
|
+
#
|
|
16
|
+
# Tokio worker threads do not survive fork, and inherited pooled
|
|
17
|
+
# connections are not safe to reuse. This error is raised before a child
|
|
18
|
+
# can access that state.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# Process.fork do
|
|
22
|
+
# Wreq::Client.new # Raises if the parent loaded wreq-ruby.
|
|
23
|
+
# end
|
|
24
|
+
# @see https://github.com/SearchApi/wreq-ruby/blob/main/docs/fork-safety.md
|
|
25
|
+
class ForkError < RuntimeError; end
|
|
26
|
+
|
|
14
27
|
# Network connection errors
|
|
15
28
|
|
|
16
29
|
# Connection to the server failed.
|
data/lib/wreq_ruby/response.rb
CHANGED
|
@@ -8,6 +8,9 @@ unless defined?(Wreq)
|
|
|
8
8
|
# access to HTTP response data including status codes, headers, body
|
|
9
9
|
# content, and streaming capabilities.
|
|
10
10
|
#
|
|
11
|
+
# Body methods raise Wreq::ForkError if the child inherited wreq-ruby from
|
|
12
|
+
# its parent.
|
|
13
|
+
#
|
|
11
14
|
# @example Basic response handling
|
|
12
15
|
# response = client.get("https://api.example.com")
|
|
13
16
|
# puts response.status.as_int # => 200
|
|
@@ -107,6 +110,7 @@ unless defined?(Wreq)
|
|
|
107
110
|
|
|
108
111
|
# Get the response bytes as a binary string.
|
|
109
112
|
# @return [String] Response body as binary data
|
|
113
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
110
114
|
# @example
|
|
111
115
|
# binary_data = response.bytes
|
|
112
116
|
# puts binary_data.size # => 1024
|
|
@@ -122,6 +126,7 @@ unless defined?(Wreq)
|
|
|
122
126
|
# html = response.text("ISO-8859-1")
|
|
123
127
|
# puts html
|
|
124
128
|
# @raise [Wreq::DecodingError] if body cannot be decoded with the specified encoding
|
|
129
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
125
130
|
def text(default_encoding = "UTF-8")
|
|
126
131
|
end
|
|
127
132
|
|
|
@@ -132,6 +137,7 @@ unless defined?(Wreq)
|
|
|
132
137
|
#
|
|
133
138
|
# @return [Object] Parsed JSON (Hash, Array, String, Integer, Float, Boolean, nil)
|
|
134
139
|
# @raise [Wreq::DecodingError] if body is not valid JSON
|
|
140
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
135
141
|
# @example
|
|
136
142
|
# data = response.json
|
|
137
143
|
# puts data["key"]
|
|
@@ -149,6 +155,7 @@ unless defined?(Wreq)
|
|
|
149
155
|
# @raise [LocalJumpError] if called without a block
|
|
150
156
|
# @raise [Wreq::TimeoutError, Wreq::BodyError, Wreq::ConnectionResetError, Wreq::RequestError]
|
|
151
157
|
# if streaming fails while reading the response body
|
|
158
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
152
159
|
# @example Save response to file
|
|
153
160
|
# File.open("output.bin", "wb") do |f|
|
|
154
161
|
# response.chunks { |chunk| f.write(chunk) }
|
|
@@ -165,10 +172,31 @@ unless defined?(Wreq)
|
|
|
165
172
|
# Close the response and free associated resources.
|
|
166
173
|
#
|
|
167
174
|
# @return [void]
|
|
175
|
+
# @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent
|
|
168
176
|
# @example
|
|
169
177
|
# response.close
|
|
170
178
|
def close
|
|
171
179
|
end
|
|
180
|
+
|
|
181
|
+
# Return TLS information captured for this response.
|
|
182
|
+
#
|
|
183
|
+
# Returns +nil+ when +tls_info: true+ was not enabled, the response used
|
|
184
|
+
# plain HTTP, or the transport supplied no TLS information. Reading or
|
|
185
|
+
# closing the response body does not discard captured TLS data.
|
|
186
|
+
#
|
|
187
|
+
# @return [Wreq::TlsInfo, nil] TLS information for this response, or +nil+
|
|
188
|
+
# when unavailable
|
|
189
|
+
# @example
|
|
190
|
+
# client = Wreq::Client.new(tls_info: true)
|
|
191
|
+
# response = client.get("https://example.com")
|
|
192
|
+
# tls = response.tls_info
|
|
193
|
+
#
|
|
194
|
+
# if tls
|
|
195
|
+
# tls.peer_certificate # => DER-encoded binary String
|
|
196
|
+
# tls.peer_certificate_chain # => frozen Array of DER binary Strings
|
|
197
|
+
# end
|
|
198
|
+
def tls_info
|
|
199
|
+
end
|
|
172
200
|
end
|
|
173
201
|
end
|
|
174
202
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
unless defined?(Wreq)
|
|
4
|
+
module Wreq
|
|
5
|
+
# Peer certificate data captured for one HTTPS response.
|
|
6
|
+
#
|
|
7
|
+
# Instances are returned by {Wreq::Response#tls_info}. Certificate bytes
|
|
8
|
+
# remain available after the response body is read or closed, even if the
|
|
9
|
+
# connection is later reused.
|
|
10
|
+
#
|
|
11
|
+
# The returned certificate Strings are Ruby-owned copies. Changing one does
|
|
12
|
+
# not alter the stored TLS data or values returned by later calls. The chain
|
|
13
|
+
# Array is frozen, but its String elements remain mutable.
|
|
14
|
+
#
|
|
15
|
+
# Certificates use the DER encoding described by the X.509 profile in
|
|
16
|
+
# RFC 5280.
|
|
17
|
+
#
|
|
18
|
+
# @example Parse the peer certificate with OpenSSL
|
|
19
|
+
# require "openssl"
|
|
20
|
+
#
|
|
21
|
+
# client = Wreq::Client.new(tls_info: true)
|
|
22
|
+
# response = client.get("https://example.com")
|
|
23
|
+
# der = response.tls_info&.peer_certificate
|
|
24
|
+
#
|
|
25
|
+
# if der
|
|
26
|
+
# certificate = OpenSSL::X509::Certificate.new(der)
|
|
27
|
+
# puts certificate.subject
|
|
28
|
+
# end
|
|
29
|
+
# @see https://www.rfc-editor.org/rfc/rfc5280#section-4.1 X.509 certificate format
|
|
30
|
+
class TlsInfo
|
|
31
|
+
# Return the peer's leaf certificate.
|
|
32
|
+
#
|
|
33
|
+
# @return [String, nil] a new DER-encoded String with
|
|
34
|
+
# +Encoding::BINARY+, or +nil+ when the transport did not provide one
|
|
35
|
+
def peer_certificate
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Return the peer certificate chain.
|
|
39
|
+
#
|
|
40
|
+
# The Array is frozen. Each element is a new DER-encoded binary String.
|
|
41
|
+
# The chain includes the leaf certificate when the transport supplies it.
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<String>, nil] a frozen Array of certificate copies, or
|
|
44
|
+
# +nil+ when the transport did not provide a chain
|
|
45
|
+
def peer_certificate_chain
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ======================== Ruby API Extensions ========================
|
|
52
|
+
|
|
53
|
+
module Wreq
|
|
54
|
+
class TlsInfo
|
|
55
|
+
# Return a compact summary for debugging.
|
|
56
|
+
#
|
|
57
|
+
# The summary reports the leaf certificate size and the number of
|
|
58
|
+
# certificates in the chain without printing the DER data.
|
|
59
|
+
#
|
|
60
|
+
# @return [String] TLS certificate metadata
|
|
61
|
+
# @example
|
|
62
|
+
# tls_info.inspect
|
|
63
|
+
# # => "#<Wreq::TlsInfo peer_certificate=781B peer_certificate_chain=1>"
|
|
64
|
+
def inspect
|
|
65
|
+
certificate = peer_certificate
|
|
66
|
+
chain = peer_certificate_chain
|
|
67
|
+
certificate_size = certificate ? "#{certificate.bytesize}B" : "nil"
|
|
68
|
+
chain_size = chain ? chain.length : "nil"
|
|
69
|
+
|
|
70
|
+
"#<#{self.class} peer_certificate=#{certificate_size} peer_certificate_chain=#{chain_size}>"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/test/fork_test.rb
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
before_runtime
|
|
11
|
+
invalid_client
|
|
12
|
+
invalid_request
|
|
13
|
+
fresh_body_sender
|
|
14
|
+
inherited_body_sender_push
|
|
15
|
+
inherited_body_sender_close
|
|
16
|
+
inherited_body_sender_closed
|
|
17
|
+
fresh_client
|
|
18
|
+
inherited_client
|
|
19
|
+
inherited_response
|
|
20
|
+
inherited_response_text
|
|
21
|
+
inherited_response_chunks
|
|
22
|
+
inherited_response_close
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
def test_fork_error_is_a_runtime_error
|
|
26
|
+
assert_operator Wreq::ForkError, :<, RuntimeError
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def test_loaded_extension_is_rejected_after_fork
|
|
30
|
+
skip "fork is not supported on this platform" unless Process.respond_to?(:fork)
|
|
31
|
+
|
|
32
|
+
stdout, stderr, status = run_fork_script("fork_safety.rb")
|
|
33
|
+
|
|
34
|
+
assert status.success?, "subprocess failed with #{status.inspect}: #{stderr}"
|
|
35
|
+
assert_equal "ok\n", stdout
|
|
36
|
+
FORK_ERROR_LABELS.each do |label|
|
|
37
|
+
assert_match(/#{label}=Wreq::ForkError:.*cannot be used after fork/, stderr)
|
|
38
|
+
assert_match(/#{label}_retry=Wreq::ForkError:.*cannot be used after fork/, stderr)
|
|
39
|
+
end
|
|
40
|
+
assert_match(/inherited_gc=ok/, stderr)
|
|
41
|
+
refute_match(/\[BUG\]|segmentation fault|panicked/i, stderr)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def run_fork_script(name)
|
|
47
|
+
lib_dir = File.expand_path("../lib", __dir__)
|
|
48
|
+
script = File.expand_path("scripts/#{name}", __dir__)
|
|
49
|
+
|
|
50
|
+
Tempfile.create("wreq-fork-stdout") do |stdout|
|
|
51
|
+
Tempfile.create("wreq-fork-stderr") do |stderr|
|
|
52
|
+
pid = Process.spawn(
|
|
53
|
+
RbConfig.ruby,
|
|
54
|
+
"-I",
|
|
55
|
+
lib_dir,
|
|
56
|
+
script,
|
|
57
|
+
out: stdout,
|
|
58
|
+
err: stderr,
|
|
59
|
+
pgroup: true
|
|
60
|
+
)
|
|
61
|
+
status = Timeout.timeout(30) { Process.wait2(pid).last }
|
|
62
|
+
stdout.rewind
|
|
63
|
+
stderr.rewind
|
|
64
|
+
return [stdout.read, stderr.read, status]
|
|
65
|
+
rescue Timeout::Error
|
|
66
|
+
begin
|
|
67
|
+
Process.kill("KILL", -pid)
|
|
68
|
+
rescue Errno::ESRCH
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
Process.wait(pid)
|
|
74
|
+
rescue Errno::ECHILD
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
flunk "#{name} timed out"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
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
|
+
child_pid = fork do
|
|
13
|
+
2.times do |attempt|
|
|
14
|
+
attempt_label = attempt.zero? ? label : "#{label}_retry"
|
|
15
|
+
|
|
16
|
+
begin
|
|
17
|
+
Timeout.timeout(5) { yield }
|
|
18
|
+
rescue Wreq::ForkError => error
|
|
19
|
+
warn "#{attempt_label}=#{error.class}: #{error.message}"
|
|
20
|
+
next
|
|
21
|
+
rescue => error
|
|
22
|
+
warn "#{attempt_label}=unexpected #{error.class}: #{error.message}"
|
|
23
|
+
exit! 2
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
warn "#{attempt_label}=missing Wreq::ForkError"
|
|
27
|
+
exit! 3
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
exit! 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
_, status = Process.wait2(child_pid)
|
|
34
|
+
abort "#{label} child failed with #{status.inspect}" unless status.success?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
expect_fork_error("before_runtime") { Wreq::Client.new }
|
|
38
|
+
expect_fork_error("invalid_client") { Wreq::Client.new(unknown: true) }
|
|
39
|
+
expect_fork_error("invalid_request") { Wreq.get(1) }
|
|
40
|
+
expect_fork_error("fresh_body_sender") { Wreq::BodySender.new(0) }
|
|
41
|
+
|
|
42
|
+
server = TCPServer.new("127.0.0.1", 0)
|
|
43
|
+
port = server.addr[1]
|
|
44
|
+
server_pid = fork do
|
|
45
|
+
3.times do
|
|
46
|
+
ready = IO.select([server], nil, nil, 10)
|
|
47
|
+
exit! 4 unless ready
|
|
48
|
+
|
|
49
|
+
socket = server.accept
|
|
50
|
+
begin
|
|
51
|
+
while (line = socket.gets)
|
|
52
|
+
break if line == "\r\n"
|
|
53
|
+
end
|
|
54
|
+
socket.write("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
|
|
55
|
+
ensure
|
|
56
|
+
socket.close
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
exit! 0
|
|
60
|
+
ensure
|
|
61
|
+
server.close
|
|
62
|
+
end
|
|
63
|
+
server.close
|
|
64
|
+
|
|
65
|
+
url = "http://127.0.0.1:#{port}/"
|
|
66
|
+
client = Wreq::Client.new
|
|
67
|
+
abort "parent warm-up failed" unless client.get(url).bytes == "ok"
|
|
68
|
+
|
|
69
|
+
def build_inherited_objects(client, url)
|
|
70
|
+
[Wreq::Client.new, Wreq::BodySender.new, client.get(url)]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
inherited_objects = build_inherited_objects(client, url)
|
|
74
|
+
inherited_weak_refs = inherited_objects.map { |object| WeakRef.new(object) }
|
|
75
|
+
|
|
76
|
+
expect_fork_error("inherited_body_sender_push") do
|
|
77
|
+
inherited_objects[1].push("chunk")
|
|
78
|
+
end
|
|
79
|
+
expect_fork_error("inherited_body_sender_close") { inherited_objects[1].close }
|
|
80
|
+
expect_fork_error("inherited_body_sender_closed") { inherited_objects[1].closed? }
|
|
81
|
+
expect_fork_error("fresh_client") { Wreq::Client.new }
|
|
82
|
+
expect_fork_error("inherited_client") { client.get(url) }
|
|
83
|
+
expect_fork_error("inherited_response") { inherited_objects[2].bytes }
|
|
84
|
+
expect_fork_error("inherited_response_text") { inherited_objects[2].text(1) }
|
|
85
|
+
expect_fork_error("inherited_response_chunks") { inherited_objects[2].chunks }
|
|
86
|
+
expect_fork_error("inherited_response_close") { inherited_objects[2].close }
|
|
87
|
+
|
|
88
|
+
# Release the earlier test blocks so this array is the only strong reference.
|
|
89
|
+
GC.start(full_mark: true, immediate_sweep: true)
|
|
90
|
+
gc_pid = fork do
|
|
91
|
+
inherited_objects = nil
|
|
92
|
+
3.times { GC.start(full_mark: true, immediate_sweep: true) }
|
|
93
|
+
alive = inherited_weak_refs.each_index.select do |index|
|
|
94
|
+
inherited_weak_refs[index].weakref_alive?
|
|
95
|
+
end
|
|
96
|
+
abort "inherited objects were not collected: #{alive.join(", ")}" unless alive.empty?
|
|
97
|
+
warn "inherited_gc=ok"
|
|
98
|
+
exit! 0
|
|
99
|
+
rescue => error
|
|
100
|
+
warn "inherited_gc=unexpected #{error.class}: #{error.message}"
|
|
101
|
+
exit! 5
|
|
102
|
+
end
|
|
103
|
+
_, gc_status = Process.wait2(gc_pid)
|
|
104
|
+
abort "inherited GC child failed with #{gc_status.inspect}" unless gc_status.success?
|
|
105
|
+
|
|
106
|
+
abort "parent request after fork failed" unless client.get(url).bytes == "ok"
|
|
107
|
+
_, server_status = Process.wait2(server_pid)
|
|
108
|
+
abort "server failed with #{server_status.inspect}" unless server_status.success?
|
|
109
|
+
|
|
110
|
+
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,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wreq
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.2.
|
|
4
|
+
version: 1.2.12
|
|
5
5
|
platform: aarch64-linux
|
|
6
6
|
authors:
|
|
7
7
|
- SearchApi
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: An easy and powerful Ruby HTTP client with advanced browser fingerprinting
|
|
14
14
|
that accurately emulates Chrome, Edge, Firefox, Safari, Opera, and OkHttp with precise
|
|
@@ -38,6 +38,8 @@ files:
|
|
|
38
38
|
- crates/wreq-util/README.md
|
|
39
39
|
- crates/wreq-util/release-plz.toml
|
|
40
40
|
- crates/wreq-util/rustfmt.toml
|
|
41
|
+
- docs/fork-safety.md
|
|
42
|
+
- docs/interrupt-handling.md
|
|
41
43
|
- docs/windows-gnu-tokio-crash.md
|
|
42
44
|
- examples/body.rb
|
|
43
45
|
- examples/client.rb
|
|
@@ -48,6 +50,7 @@ files:
|
|
|
48
50
|
- examples/send_stream.rb
|
|
49
51
|
- examples/stream.rb
|
|
50
52
|
- examples/thread_interrupt.rb
|
|
53
|
+
- examples/tls_info.rb
|
|
51
54
|
- lib/wreq.rb
|
|
52
55
|
- lib/wreq_ruby/3.3/wreq_ruby.so
|
|
53
56
|
- lib/wreq_ruby/3.4/wreq_ruby.so
|
|
@@ -60,6 +63,7 @@ files:
|
|
|
60
63
|
- lib/wreq_ruby/header.rb
|
|
61
64
|
- lib/wreq_ruby/http.rb
|
|
62
65
|
- lib/wreq_ruby/response.rb
|
|
66
|
+
- lib/wreq_ruby/tls.rb
|
|
63
67
|
- script/build_platform_gem.rb
|
|
64
68
|
- script/rust_env.rb
|
|
65
69
|
- test/body_sender_test.rb
|
|
@@ -68,6 +72,7 @@ files:
|
|
|
68
72
|
- test/cookie_test.rb
|
|
69
73
|
- test/emulation_test.rb
|
|
70
74
|
- test/error_handling_test.rb
|
|
75
|
+
- test/fork_test.rb
|
|
71
76
|
- test/header_test.rb
|
|
72
77
|
- test/inspect_test.rb
|
|
73
78
|
- test/json_precision_test.rb
|
|
@@ -77,8 +82,11 @@ files:
|
|
|
77
82
|
- test/request_parameters_test.rb
|
|
78
83
|
- test/request_test.rb
|
|
79
84
|
- test/response_test.rb
|
|
85
|
+
- test/scripts/fork_safety.rb
|
|
80
86
|
- test/stream_test.rb
|
|
87
|
+
- test/support/tls_server.rb
|
|
81
88
|
- test/test_helper.rb
|
|
89
|
+
- test/tls_info_test.rb
|
|
82
90
|
- test/value_semantics_test.rb
|
|
83
91
|
- wreq.gemspec
|
|
84
92
|
homepage: https://github.com/SearchApi/wreq-ruby
|