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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8141b1cfd59a1268c9532940b86a4ae41edfde917bd568fed589e6f4baee785d
4
- data.tar.gz: dad9f6d56c1437927c96b5f13f0e0bf30e2dda5a5d912deaf58bb1c194f688ae
3
+ metadata.gz: 77c664031054cda90dc0a51bb1eb1489bd140ece7acf2016be6d316faca11798
4
+ data.tar.gz: 41669054ee2c4d3abd8ec8fbcc162a8b10d94261b8db26ca0d43352891a60fda
5
5
  SHA512:
6
- metadata.gz: f5b842f60b928099b18bcc57be0a2ca847dbb9b4567ee1634fcaa888e0a87e98c52c7e9e01d54101ef20897abc80ed8e22a14e01d4e8ad5101059a6f7425073e
7
- data.tar.gz: 7c9f7da692dbd6cae90720b436b9918d7bb48f924aa8791c7bdf00e5751364e8f2b7393fcc3f168af468aa1d7e674e03bb6dc6fb0914c87c6fff5e994559bdfd
6
+ metadata.gz: 9c1a1bed05c48b912a4221c16bcf095bf4ed21e064d00a8add61c67fa23376f5217cefb3c47b7546810f85082d8e60e60293aa6b115c29a4e0c1adcc15b03b70
7
+ data.tar.gz: 80deb6d3d1e748165bf0277aa92b568ac55dc02d8355ef25dd6c793a8610c6c298dc98c8e241628f240126cb8aa2a7360c3ebbbb0a79013e4241024a550eb9ee
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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.11
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-05 00:00:00.000000000 Z
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