wreq 1.2.11-aarch64-linux → 1.2.13-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: 3ea7be302c63749f212aee6fea894213b4efa37cc407e9ae6233157ed7abb87d
4
+ data.tar.gz: 9dc335f91758f7d0631c95c3abb83ad46a4ddf122eb58a264b0a38306a0ff9d2
5
5
  SHA512:
6
- metadata.gz: f5b842f60b928099b18bcc57be0a2ca847dbb9b4567ee1634fcaa888e0a87e98c52c7e9e01d54101ef20897abc80ed8e22a14e01d4e8ad5101059a6f7425073e
7
- data.tar.gz: 7c9f7da692dbd6cae90720b436b9918d7bb48f924aa8791c7bdf00e5751364e8f2b7393fcc3f168af468aa1d7e674e03bb6dc6fb0914c87c6fff5e994559bdfd
6
+ metadata.gz: b9d0b05eb897484c8af3fca8073121fd341a859cbd7cfb7bdda57aeaf367b2db4abc049fdb6174728683bdd647769306a6a5a2a81bc4737534c9d4f19f50f58c
7
+ data.tar.gz: 871bdefe7c23801b22dc061eb015610c18e247242493aa7dc1d7bf07ca43fa35fd7788c06948189e368dee3e404354b08d8eff2b97b31e501020b3d8f03e5dab
@@ -0,0 +1,51 @@
1
+ # Fork safety
2
+
3
+ ## Prefork checklist
4
+
5
+ - `require "wreq"` may run in the parent before workers fork.
6
+ - Create each `Wreq::Client`, `Wreq::Jar`, and `Wreq::BodySender` in the worker
7
+ that will use it.
8
+ - Keep each `Wreq::Response` in the process that received it.
9
+ - Do not start requests or push streaming body data in the parent before workers
10
+ fork.
11
+ - If the parent must use wreq-ruby first, start workers with `spawn` or `exec`
12
+ instead of `fork`.
13
+
14
+ wreq-ruby checks process ownership whenever it exposes guarded native state. It
15
+ does not copy, reset, or rebuild inherited objects.
16
+
17
+ ## Loading before fork
18
+
19
+ wreq-ruby creates its process-wide Tokio runtime on the first operation that
20
+ needs it. Requiring the gem does not initialize the runtime, so a prefork server
21
+ may load wreq-ruby during boot. Each worker can then create its own runtime on
22
+ its first request without an `after_fork!` hook.
23
+
24
+ Create clients and other native-backed objects inside the worker. Each client,
25
+ response, body sender, and cookie jar belongs to the process that created it.
26
+ Using an inherited object raises `Wreq::ForkError`, even when the parent never
27
+ started the runtime. wreq-ruby does not rebuild these objects.
28
+
29
+ ## Forking after runtime initialization
30
+
31
+ Once the parent starts an HTTP operation or otherwise uses the Tokio runtime, a
32
+ forked child must not reuse it. Tokio's worker threads do not survive `fork`, and
33
+ the inherited connection pool may refer to those missing threads.
34
+
35
+ Operations that need the inherited runtime raise `Wreq::ForkError`. This
36
+ includes requests through new or existing clients, module request methods, and
37
+ streaming request writes. Constructing a new client, body sender, or cookie jar
38
+ does not use the runtime, but runtime-backed operations remain unavailable in
39
+ that child. Retrying them raises the same error.
40
+
41
+ An inherited `Wreq::Response` cannot be used at all. This includes status,
42
+ headers, socket addresses, TLS information, and body methods. Values copied out
43
+ before the fork, such as a `Wreq::StatusCode` or `Wreq::TlsInfo`, are separate
44
+ objects and do not retain access to the response.
45
+
46
+ The parent remains usable. Native objects collected in the child do not destroy
47
+ state owned by the parent process.
48
+
49
+ Use a spawn- or exec-based worker when the parent must perform HTTP work before
50
+ workers start. Requiring the extension again cannot replace an inherited
51
+ runtime.
@@ -0,0 +1,134 @@
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 cancellation conversion centralized in `rt::block_on`. Request, response,
88
+ and body operations may call `block_on`, but they must not construct their own
89
+ Ruby cancellation exception. `block_on` returns a future's native error
90
+ unchanged so the caller can convert it after the GVL has been reacquired.
91
+
92
+ These forms are forbidden for wreq-owned cancellation:
93
+
94
+ ```rust
95
+ MagnusError::new(ruby.exception_interrupt(), "request interrupted")
96
+ ```
97
+
98
+ ```ruby
99
+ raise Interrupt, "request interrupted"
100
+ ```
101
+
102
+ Using `exception_interrupt` as the parent when defining
103
+ `Wreq::InterruptError` is still required. Using it as the class passed to
104
+ `MagnusError::new` is not.
105
+
106
+ ## Review checklist
107
+
108
+ - Reject direct construction or raising of Ruby's built-in `Interrupt` for a
109
+ wreq-owned cancellation.
110
+ - Keep `Wreq::InterruptError` as a direct subclass of `Interrupt`.
111
+ - Keep Ruby API calls and exception construction out of the no-GVL callback
112
+ and UBF.
113
+ - Preserve an exception supplied by Ruby through `Thread#raise`.
114
+ - Do not turn `Thread#kill`, `Thread#terminate`, or `Thread#exit` into a new
115
+ exception.
116
+ - Test the real cancellation path, the exception hierarchy, and the
117
+ `StandardError` boundary when changing this code.
118
+
119
+ ## Ruby references
120
+
121
+ - [Ruby `Interrupt`](https://docs.ruby-lang.org/en/3.4/Interrupt.html) explains
122
+ that the class represents an interrupt signal, usually Control-C, and
123
+ inherits from `SignalException`.
124
+ - [Ruby's built-in exception hierarchy](https://docs.ruby-lang.org/en/4.0/Exception.html#class-Exception-label-Built-In+Exception+Class+Hierarchy)
125
+ shows that `SignalException` and `StandardError` are separate branches.
126
+ - [`Thread#raise`](https://docs.ruby-lang.org/en/4.0/Thread.html#method-i-raise)
127
+ raises the caller-supplied exception in another thread.
128
+ - [`Thread#kill`](https://docs.ruby-lang.org/en/4.0/Thread.html#method-i-kill)
129
+ documents asynchronous termination and its `terminate` and `exit` aliases.
130
+ - [`rb_thread_call_without_gvl`](https://docs.ruby-lang.org/capi/en/master/d6/dfb/include_2ruby_2thread_8h.html)
131
+ documents interrupt checks, GVL reacquisition, UBF cancellation, and the
132
+ restriction on Ruby API calls from no-GVL callbacks.
133
+ - [Issue #111](https://github.com/SearchApi/wreq-ruby/issues/111) contains the
134
+ 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,12 +12,40 @@ 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"
18
19
  require_relative "wreq_ruby/cookie"
19
20
 
20
21
  unless defined?(Wreq)
22
+ # An HTTP client backed by a lazily initialized, process-wide Tokio runtime.
23
+ #
24
+ # Loading wreq-ruby before `fork` is supported. The parent must not send a
25
+ # request or perform another operation that starts the runtime before workers
26
+ # are forked. Create clients and begin HTTP work inside each worker so it gets
27
+ # its own runtime and connection pool. Clients, responses, body senders, and
28
+ # cookie jars belong to the process that created them and must be recreated
29
+ # in the worker. wreq-ruby does not rebuild inherited objects.
30
+ #
31
+ # Accessing an inherited native-backed object raises Wreq::ForkError even if
32
+ # the parent did not start the runtime. If the parent did start it, the child
33
+ # also cannot perform new runtime-backed operations. Retrying does not replace
34
+ # either kind of inherited state. Use `spawn` or `exec`, or move the parent's
35
+ # HTTP work until after the workers have been forked.
36
+ #
37
+ # @example Preload the extension, then start HTTP work in the worker
38
+ # require "wreq"
39
+ #
40
+ # Process.fork do
41
+ # client = Wreq::Client.new
42
+ # response = client.get("https://example.com")
43
+ # puts response.status
44
+ # end
45
+ #
46
+ # @note Fork safety Create clients, cookie jars, and body senders inside the
47
+ # worker that uses them. Do not carry responses across `fork`.
48
+ # @see https://github.com/SearchApi/wreq-ruby/blob/main/docs/fork-safety.md
21
49
  module Wreq
22
50
  # Current wreq gem version.
23
51
  # @return [String]
@@ -60,6 +88,7 @@ unless defined?(Wreq)
60
88
  # @return [Wreq::Response] HTTP response
61
89
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
62
90
  # value cannot be converted, validated, or built
91
+ # @raise [Wreq::ForkError] if the child inherited an initialized wreq-ruby runtime
63
92
  def self.request(method, url, **options)
64
93
  end
65
94
 
@@ -93,6 +122,7 @@ unless defined?(Wreq)
93
122
  # @return [Wreq::Response] HTTP response
94
123
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
95
124
  # value cannot be converted, validated, or built
125
+ # @raise [Wreq::ForkError] if the child inherited an initialized wreq-ruby runtime
96
126
  def self.get(url, **options)
97
127
  end
98
128
 
@@ -126,6 +156,7 @@ unless defined?(Wreq)
126
156
  # @return [Wreq::Response] HTTP response
127
157
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
128
158
  # value cannot be converted, validated, or built
159
+ # @raise [Wreq::ForkError] if the child inherited an initialized wreq-ruby runtime
129
160
  def self.head(url, **options)
130
161
  end
131
162
 
@@ -159,6 +190,7 @@ unless defined?(Wreq)
159
190
  # @return [Wreq::Response] HTTP response
160
191
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
161
192
  # value cannot be converted, validated, or built
193
+ # @raise [Wreq::ForkError] if the child inherited an initialized wreq-ruby runtime
162
194
  def self.post(url, **options)
163
195
  end
164
196
 
@@ -192,6 +224,7 @@ unless defined?(Wreq)
192
224
  # @return [Wreq::Response] HTTP response
193
225
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
194
226
  # value cannot be converted, validated, or built
227
+ # @raise [Wreq::ForkError] if the child inherited an initialized wreq-ruby runtime
195
228
  def self.put(url, **options)
196
229
  end
197
230
 
@@ -225,6 +258,7 @@ unless defined?(Wreq)
225
258
  # @return [Wreq::Response] HTTP response
226
259
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
227
260
  # value cannot be converted, validated, or built
261
+ # @raise [Wreq::ForkError] if the child inherited an initialized wreq-ruby runtime
228
262
  def self.delete(url, **options)
229
263
  end
230
264
 
@@ -258,6 +292,7 @@ unless defined?(Wreq)
258
292
  # @return [Wreq::Response] HTTP response
259
293
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
260
294
  # value cannot be converted, validated, or built
295
+ # @raise [Wreq::ForkError] if the child inherited an initialized wreq-ruby runtime
261
296
  def self.options(url, **options)
262
297
  end
263
298
 
@@ -291,6 +326,7 @@ unless defined?(Wreq)
291
326
  # @return [Wreq::Response] HTTP response
292
327
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
293
328
  # value cannot be converted, validated, or built
329
+ # @raise [Wreq::ForkError] if the child inherited an initialized wreq-ruby runtime
294
330
  def self.trace(url, **options)
295
331
  end
296
332
 
@@ -324,6 +360,7 @@ unless defined?(Wreq)
324
360
  # @return [Wreq::Response] HTTP response
325
361
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
326
362
  # value cannot be converted, validated, or built
363
+ # @raise [Wreq::ForkError] if the child inherited an initialized wreq-ruby runtime
327
364
  def self.patch(url, **options)
328
365
  end
329
366
  end
Binary file
Binary file
Binary file
@@ -17,6 +17,12 @@ 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 a sender does not initialize Tokio. An inherited sender raises
21
+ # Wreq::ForkError before its channel is accessed. A new sender can be
22
+ # created in a child, but pushing data also requires a usable runtime.
23
+ #
24
+ # @note Fork safety Create each sender in the worker that writes to it.
25
+ # Do not pass a sender through `fork`.
20
26
  class BodySender
21
27
  # Create a bounded request-body sender.
22
28
  #
@@ -33,6 +39,7 @@ unless defined?(Wreq)
33
39
  # @param data [String] binary chunk
34
40
  # @return [nil]
35
41
  # @raise [IOError] if the sender or receiving side is closed
42
+ # @raise [Wreq::ForkError] if the sender or runtime belongs to the parent process
36
43
  def push(data)
37
44
  end
38
45
 
@@ -41,6 +48,7 @@ unless defined?(Wreq)
41
48
  # This operation is idempotent.
42
49
  #
43
50
  # @return [nil]
51
+ # @raise [Wreq::ForkError] if the sender belongs to the parent process
44
52
  def close
45
53
  end
46
54
 
@@ -50,6 +58,7 @@ unless defined?(Wreq)
50
58
  # the receiving side.
51
59
  #
52
60
  # @return [Boolean]
61
+ # @raise [Wreq::ForkError] if the sender belongs to the parent process
53
62
  def closed?
54
63
  end
55
64
  end
@@ -17,6 +17,15 @@ 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 client belongs to the process that created it. An inherited client
21
+ # raises Wreq::ForkError before its connection pool is accessed. Loading
22
+ # the gem before fork is supported, but clients must be created inside the
23
+ # worker. If the parent already started the runtime, new clients can be
24
+ # constructed in the child but cannot send requests.
25
+ #
26
+ # @note Fork safety Create each client in the worker that uses it. An
27
+ # inherited client is never rebuilt automatically.
28
+ #
20
29
  # @example Basic usage
21
30
  # client = Wreq::Client.new
22
31
  # # Use client for HTTP requests
@@ -130,6 +139,11 @@ unless defined?(Wreq)
130
139
  # including self-signed or expired ones. Should only be disabled
131
140
  # for testing purposes.
132
141
  #
142
+ # @param tls_info [Boolean, nil] Retain peer certificate data for HTTPS
143
+ # responses. When true, {Wreq::Response#tls_info} may return a
144
+ # {Wreq::TlsInfo} object. Disabled by default because retaining
145
+ # certificate data uses additional memory.
146
+ #
133
147
  # @param no_proxy [Boolean, nil] Disable use of any configured proxy
134
148
  # for this client, even if proxy settings are detected from the
135
149
  # environment.
@@ -165,7 +179,7 @@ unless defined?(Wreq)
165
179
  # value cannot be converted or validated.
166
180
  # @raise [Wreq::BuilderError, Wreq::TlsError] if the native client cannot
167
181
  # be initialized.
168
- #
182
+ # @raise [Wreq::ForkError] if :cookie_provider belongs to a parent process.
169
183
  # @example Minimal client
170
184
  # client = Wreq::Client.new
171
185
  #
@@ -280,6 +294,7 @@ unless defined?(Wreq)
280
294
  # or unavailable on the current platform
281
295
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
282
296
  # value cannot be converted, validated, or built
297
+ # @raise [Wreq::ForkError] if the client or runtime belongs to the parent process
283
298
  def request(method, url, **options)
284
299
  end
285
300
 
@@ -313,6 +328,7 @@ unless defined?(Wreq)
313
328
  # @return [Wreq::Response] HTTP response
314
329
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
315
330
  # value cannot be converted, validated, or built
331
+ # @raise [Wreq::ForkError] if the client or runtime belongs to the parent process
316
332
  def get(url, **options)
317
333
  end
318
334
 
@@ -346,6 +362,7 @@ unless defined?(Wreq)
346
362
  # @return [Wreq::Response] HTTP response
347
363
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
348
364
  # value cannot be converted, validated, or built
365
+ # @raise [Wreq::ForkError] if the client or runtime belongs to the parent process
349
366
  def head(url, **options)
350
367
  end
351
368
 
@@ -379,6 +396,7 @@ unless defined?(Wreq)
379
396
  # @return [Wreq::Response] HTTP response
380
397
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
381
398
  # value cannot be converted, validated, or built
399
+ # @raise [Wreq::ForkError] if the client or runtime belongs to the parent process
382
400
  def post(url, **options)
383
401
  end
384
402
 
@@ -412,6 +430,7 @@ unless defined?(Wreq)
412
430
  # @return [Wreq::Response] HTTP response
413
431
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
414
432
  # value cannot be converted, validated, or built
433
+ # @raise [Wreq::ForkError] if the client or runtime belongs to the parent process
415
434
  def put(url, **options)
416
435
  end
417
436
 
@@ -445,6 +464,7 @@ unless defined?(Wreq)
445
464
  # @return [Wreq::Response] HTTP response
446
465
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
447
466
  # value cannot be converted, validated, or built
467
+ # @raise [Wreq::ForkError] if the client or runtime belongs to the parent process
448
468
  def delete(url, **options)
449
469
  end
450
470
 
@@ -478,6 +498,7 @@ unless defined?(Wreq)
478
498
  # @return [Wreq::Response] HTTP response
479
499
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
480
500
  # value cannot be converted, validated, or built
501
+ # @raise [Wreq::ForkError] if the client or runtime belongs to the parent process
481
502
  def options(url, **options)
482
503
  end
483
504
 
@@ -511,6 +532,7 @@ unless defined?(Wreq)
511
532
  # @return [Wreq::Response] HTTP response
512
533
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
513
534
  # value cannot be converted, validated, or built
535
+ # @raise [Wreq::ForkError] if the client or runtime belongs to the parent process
514
536
  def trace(url, **options)
515
537
  end
516
538
 
@@ -544,6 +566,7 @@ unless defined?(Wreq)
544
566
  # @return [Wreq::Response] HTTP response
545
567
  # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option
546
568
  # value cannot be converted, validated, or built
569
+ # @raise [Wreq::ForkError] if the client or runtime belongs to the parent process
547
570
  def patch(url, **options)
548
571
  end
549
572
  end
@@ -158,6 +158,11 @@ unless defined?(Wreq)
158
158
  # Stores cookies for reuse across requests.
159
159
  #
160
160
  # Pass a Jar to Wreq::Client as `cookie_provider` to share its cookies.
161
+ # A jar belongs to the process that created it and cannot be inherited
162
+ # across `fork`.
163
+ #
164
+ # @note Fork safety Create a new jar in each worker. wreq-ruby does not
165
+ # copy cookies from an inherited jar.
161
166
  class Jar
162
167
  # Creates an empty cookie jar.
163
168
  # @return [Wreq::Jar]
@@ -166,6 +171,7 @@ unless defined?(Wreq)
166
171
 
167
172
  # Returns all stored cookies.
168
173
  # @return [Array<Wreq::Cookie>]
174
+ # @raise [Wreq::ForkError] if the jar belongs to the parent process
169
175
  def get_all
170
176
  end
171
177
 
@@ -174,6 +180,7 @@ unless defined?(Wreq)
174
180
  # @param url [String] URL that scopes the cookie
175
181
  # @return [void]
176
182
  # @raise [TypeError] if cookie is neither a String nor Wreq::Cookie
183
+ # @raise [Wreq::ForkError] if the jar belongs to the parent process
177
184
  def add(cookie, url)
178
185
  end
179
186
 
@@ -181,11 +188,13 @@ unless defined?(Wreq)
181
188
  # @param name [String]
182
189
  # @param url [String]
183
190
  # @return [void]
191
+ # @raise [Wreq::ForkError] if the jar belongs to the parent process
184
192
  def remove(name, url)
185
193
  end
186
194
 
187
195
  # Clear all cookies from the jar.
188
196
  # @return [void]
197
+ # @raise [Wreq::ForkError] if the jar belongs to the parent process
189
198
  def clear
190
199
  end
191
200
  end
@@ -11,6 +11,21 @@ unless defined?(Wreq)
11
11
  # Memory allocation failed.
12
12
  class MemoryError < StandardError; end
13
13
 
14
+ # The child process tried to use native state created by its parent.
15
+ #
16
+ # Tokio worker threads do not survive fork. Inherited connection pools,
17
+ # locks, channels, and response state are also unsafe to use. wreq-ruby
18
+ # raises this error before exposing them. Loading the gem before fork is
19
+ # supported, but native-backed objects must be created in each worker.
20
+ #
21
+ # @example
22
+ # client = Wreq::Client.new
23
+ # Process.fork do
24
+ # client.get("https://example.com") # Raises in the child.
25
+ # end
26
+ # @see https://github.com/SearchApi/wreq-ruby/blob/main/docs/fork-safety.md
27
+ class ForkError < RuntimeError; end
28
+
14
29
  # Network connection errors
15
30
 
16
31
  # Connection to the server failed.
@@ -8,6 +8,13 @@ unless defined?(Wreq)
8
8
  # access to HTTP response data including status codes, headers, body
9
9
  # content, and streaming capabilities.
10
10
  #
11
+ # A response belongs to the process that received it. Accessing its metadata
12
+ # or body after inheriting it from a parent raises Wreq::ForkError.
13
+ #
14
+ # @note Fork safety Keep each response in the process that received it.
15
+ # Issue a new request in the worker instead of carrying a response through
16
+ # `fork`.
17
+ #
11
18
  # @example Basic response handling
12
19
  # response = client.get("https://api.example.com")
13
20
  # puts response.status.as_int # => 200
@@ -26,6 +33,7 @@ unless defined?(Wreq)
26
33
  # Get the HTTP status code as an integer.
27
34
  #
28
35
  # @return [Integer] Status code (e.g., 200, 404, 500)
36
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
29
37
  # @example
30
38
  # response.code # => 200
31
39
  def code
@@ -34,6 +42,7 @@ unless defined?(Wreq)
34
42
  # Get the HTTP status code object.
35
43
  #
36
44
  # @return [Wreq::StatusCode] Status code wrapper with helper methods
45
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
37
46
  # @example
38
47
  # status = response.status
39
48
  # status.success? # => true
@@ -43,6 +52,7 @@ unless defined?(Wreq)
43
52
  # Get the HTTP protocol version used.
44
53
  #
45
54
  # @return [Wreq::Version] HTTP version (HTTP/1.1, HTTP/2, etc.)
55
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
46
56
  # @example
47
57
  # response.version # => Wreq::Version::HTTP_11
48
58
  def version
@@ -51,6 +61,7 @@ unless defined?(Wreq)
51
61
  # Get the final URL after redirects.
52
62
  #
53
63
  # @return [String] The final URL
64
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
54
65
  # @example
55
66
  # response.url # => "https://example.com/final-page"
56
67
  def url
@@ -59,6 +70,7 @@ unless defined?(Wreq)
59
70
  # Get the content length if known.
60
71
  #
61
72
  # @return [Integer, nil] Content length in bytes, or nil if unknown
73
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
62
74
  # @example
63
75
  # response.content_length # => 1024
64
76
  def content_length
@@ -72,6 +84,7 @@ unless defined?(Wreq)
72
84
  # response or a later snapshot, and object identity is not guaranteed.
73
85
  #
74
86
  # @return [Wreq::Headers] Response headers
87
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
75
88
  # @example
76
89
  # response.headers.get("content-type") # => "application/json"
77
90
  def headers
@@ -80,6 +93,7 @@ unless defined?(Wreq)
80
93
  # Get the local socket address.
81
94
  #
82
95
  # @return [String, nil] Local address (e.g., "127.0.0.1:54321"), or nil
96
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
83
97
  # @example
84
98
  # response.local_addr # => "192.168.1.100:54321"
85
99
  def local_addr
@@ -88,6 +102,7 @@ unless defined?(Wreq)
88
102
  # Get the remote socket address.
89
103
  #
90
104
  # @return [String, nil] Remote address (e.g., "93.184.216.34:443"), or nil
105
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
91
106
  # @example
92
107
  # response.remote_addr # => "93.184.216.34:443"
93
108
  def remote_addr
@@ -98,6 +113,7 @@ unless defined?(Wreq)
98
113
  # Invalid `Set-Cookie` values are skipped.
99
114
  #
100
115
  # @return [Array<Wreq::Cookie>] Parsed response cookies
116
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
101
117
  # @example
102
118
  # response.cookies.each do |cookie|
103
119
  # puts "#{cookie.name}=#{cookie.value}"
@@ -107,6 +123,7 @@ unless defined?(Wreq)
107
123
 
108
124
  # Get the response bytes as a binary string.
109
125
  # @return [String] Response body as binary data
126
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
110
127
  # @example
111
128
  # binary_data = response.bytes
112
129
  # puts binary_data.size # => 1024
@@ -122,6 +139,7 @@ unless defined?(Wreq)
122
139
  # html = response.text("ISO-8859-1")
123
140
  # puts html
124
141
  # @raise [Wreq::DecodingError] if body cannot be decoded with the specified encoding
142
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
125
143
  def text(default_encoding = "UTF-8")
126
144
  end
127
145
 
@@ -132,6 +150,7 @@ unless defined?(Wreq)
132
150
  #
133
151
  # @return [Object] Parsed JSON (Hash, Array, String, Integer, Float, Boolean, nil)
134
152
  # @raise [Wreq::DecodingError] if body is not valid JSON
153
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
135
154
  # @example
136
155
  # data = response.json
137
156
  # puts data["key"]
@@ -149,6 +168,7 @@ unless defined?(Wreq)
149
168
  # @raise [LocalJumpError] if called without a block
150
169
  # @raise [Wreq::TimeoutError, Wreq::BodyError, Wreq::ConnectionResetError, Wreq::RequestError]
151
170
  # if streaming fails while reading the response body
171
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
152
172
  # @example Save response to file
153
173
  # File.open("output.bin", "wb") do |f|
154
174
  # response.chunks { |chunk| f.write(chunk) }
@@ -165,10 +185,32 @@ unless defined?(Wreq)
165
185
  # Close the response and free associated resources.
166
186
  #
167
187
  # @return [void]
188
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
168
189
  # @example
169
190
  # response.close
170
191
  def close
171
192
  end
193
+
194
+ # Return TLS information captured for this response.
195
+ #
196
+ # Returns +nil+ when +tls_info: true+ was not enabled, the response used
197
+ # plain HTTP, or the transport supplied no TLS information. Reading or
198
+ # closing the response body does not discard captured TLS data.
199
+ #
200
+ # @return [Wreq::TlsInfo, nil] TLS information for this response, or +nil+
201
+ # when unavailable
202
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
203
+ # @example
204
+ # client = Wreq::Client.new(tls_info: true)
205
+ # response = client.get("https://example.com")
206
+ # tls = response.tls_info
207
+ #
208
+ # if tls
209
+ # tls.peer_certificate # => DER-encoded binary String
210
+ # tls.peer_certificate_chain # => frozen Array of DER binary Strings
211
+ # end
212
+ def tls_info
213
+ end
172
214
  end
173
215
  end
174
216
  end
@@ -180,6 +222,7 @@ module Wreq
180
222
  # Returns the response body as a string.
181
223
  #
182
224
  # @return [String] Response body text
225
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
183
226
  # @example
184
227
  # puts response.to_s
185
228
  # puts response
@@ -193,6 +236,7 @@ module Wreq
193
236
  # Format: #<Wreq::Response STATUS content-type="..." body=SIZE>
194
237
  #
195
238
  # @return [String] Compact formatted response information
239
+ # @raise [Wreq::ForkError] if the response belongs to the parent process
196
240
  # @example
197
241
  # p response
198
242
  # # => #<Wreq::Response 200 content-type="application/json" body=456B>
@@ -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,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,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.13
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-16 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,12 @@ 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
86
+ - test/scripts/prefork_runtime.rb
80
87
  - test/stream_test.rb
88
+ - test/support/tls_server.rb
81
89
  - test/test_helper.rb
90
+ - test/tls_info_test.rb
82
91
  - test/value_semantics_test.rb
83
92
  - wreq.gemspec
84
93
  homepage: https://github.com/SearchApi/wreq-ruby