diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26940d5..b812658 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -83,7 +83,7 @@ jobs: env: RB_SYS_CARGO_PROFILE: release run: | - cargo clean -p rb-sys -p magnus -p serde_magnus -p wreq-ruby --release 2>/dev/null || true + cargo clean -p rb-sys -p magnus -p wreq-ruby --release 2>/dev/null || true bundle exec rake compile mkdir -p lib/wreq_ruby/3.3 cp lib/wreq_ruby/wreq_ruby.bundle lib/wreq_ruby/3.3/ @@ -97,7 +97,7 @@ jobs: env: RB_SYS_CARGO_PROFILE: release run: | - cargo clean -p rb-sys -p magnus -p serde_magnus -p wreq-ruby --release + cargo clean -p rb-sys -p magnus -p wreq-ruby --release bundle exec rake compile mkdir -p lib/wreq_ruby/3.4 cp lib/wreq_ruby/wreq_ruby.bundle lib/wreq_ruby/3.4/ @@ -111,7 +111,7 @@ jobs: env: RB_SYS_CARGO_PROFILE: release run: | - cargo clean -p rb-sys -p magnus -p serde_magnus -p wreq-ruby --release + cargo clean -p rb-sys -p magnus -p wreq-ruby --release bundle exec rake compile mkdir -p lib/wreq_ruby/4.0 cp lib/wreq_ruby/wreq_ruby.bundle lib/wreq_ruby/4.0/ diff --git a/docs/fork-safety.md b/docs/fork-safety.md new file mode 100644 index 0000000..2824be3 --- /dev/null +++ b/docs/fork-safety.md @@ -0,0 +1,32 @@ +# Fork safety + +## Why inherited clients are rejected + +wreq-ruby uses a process-wide Tokio runtime and connection pool. `fork` copies +the parent's memory, but only the thread that called `fork` continues in the +child. Tokio's worker threads are gone, and its inherited tasks, locks, and +connections are not safe to reuse. + +If the parent has already loaded wreq-ruby, native HTTP operations in the child +raise `Wreq::ForkError`. This applies to new and existing clients, module +request methods, streaming request bodies, and response body methods. Retrying +the operation in the same child raises the same error. + +The parent can continue using its clients. When inherited Ruby objects are +collected in the child, their native runtime state is left for the operating +system to reclaim when the process exits. + +## Child processes are unsupported + +A process created with `fork` must not use wreq-ruby, even when it first loads +the extension after the fork. If the parent loaded wreq-ruby, native operations +in the child raise `Wreq::ForkError`. + +When the extension was not present in the parent, no wreq-ruby state or fork +marker reaches the child. The extension cannot reliably distinguish that child +from a newly started process, so this unsupported path cannot guarantee a Ruby +error and may fail inside platform libraries. + +Prefork servers should use an `exec`- or spawn-based worker model when workers +need wreq-ruby. Requiring the extension again does not reset inherited runtime +state, and there is no `after_fork!` hook. diff --git a/lib/wreq.rb b/lib/wreq.rb index 41cd88f..62820f1 100644 --- a/lib/wreq.rb +++ b/lib/wreq.rb @@ -9,6 +9,7 @@ # Load type hint definitions require_relative "wreq_ruby/http" +require_relative "wreq_ruby/emulate" require_relative "wreq_ruby/client" require_relative "wreq_ruby/response" require_relative "wreq_ruby/body" @@ -28,9 +29,8 @@ module Wreq # or native conversion, such as TypeError or Wreq::BuilderError. Validation # finishes before network I/O. # - # Requests made in a child process forked after wreq-ruby was loaded raise - # Wreq::ForkError. Load wreq-ruby inside each worker after it has been - # forked. + # If a child process inherits wreq-ruby from its parent, requests raise + # Wreq::ForkError. Require wreq after the worker has been forked. # Send an HTTP request. # @@ -50,8 +50,12 @@ module Wreq # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -63,6 +67,7 @@ module Wreq # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def self.request(method, url, **options) end @@ -83,8 +88,12 @@ def self.request(method, url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -96,6 +105,7 @@ def self.request(method, url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def self.get(url, **options) end @@ -116,8 +126,12 @@ def self.get(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -129,6 +143,7 @@ def self.get(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def self.head(url, **options) end @@ -149,8 +164,12 @@ def self.head(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -162,6 +181,7 @@ def self.head(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def self.post(url, **options) end @@ -182,8 +202,12 @@ def self.post(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -195,6 +219,7 @@ def self.post(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def self.put(url, **options) end @@ -215,8 +240,12 @@ def self.put(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -228,6 +257,7 @@ def self.put(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def self.delete(url, **options) end @@ -248,8 +278,12 @@ def self.delete(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -261,6 +295,7 @@ def self.delete(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def self.options(url, **options) end @@ -281,8 +316,12 @@ def self.options(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -294,6 +333,7 @@ def self.options(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def self.trace(url, **options) end @@ -314,8 +354,12 @@ def self.trace(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the client's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -327,6 +371,7 @@ def self.trace(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def self.patch(url, **options) end end diff --git a/lib/wreq_ruby/body.rb b/lib/wreq_ruby/body.rb index 3189f91..1860d93 100644 --- a/lib/wreq_ruby/body.rb +++ b/lib/wreq_ruby/body.rb @@ -17,8 +17,8 @@ module Wreq # # A sender can be attached to one request. Closing it prevents further writes but # retains queued chunks so a request attached afterward can still drain them. - # Calling {#push} raises Wreq::ForkError in a child process forked after - # wreq-ruby was loaded. + # Creating or using a sender raises Wreq::ForkError if the child inherited + # wreq-ruby from its parent. class BodySender # Create a bounded request-body sender. # @@ -27,6 +27,7 @@ class BodySender # @return [Wreq::BodySender] A streaming request body sender # @raise [ArgumentError] if capacity is zero, negative, or too large # @raise [TypeError] if capacity is not an Integer + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def self.new(capacity = 8) end @@ -35,7 +36,7 @@ def self.new(capacity = 8) # @param data [String] binary chunk # @return [nil] # @raise [IOError] if the sender or receiving side is closed - # @raise [Wreq::ForkError] if the process inherited wreq-ruby through fork + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def push(data) end @@ -44,6 +45,7 @@ def push(data) # This operation is idempotent. # # @return [nil] + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def close end @@ -53,6 +55,7 @@ def close # the receiving side. # # @return [Boolean] + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def closed? end end diff --git a/lib/wreq_ruby/client.rb b/lib/wreq_ruby/client.rb index d9a4cfb..dfc96ed 100644 --- a/lib/wreq_ruby/client.rb +++ b/lib/wreq_ruby/client.rb @@ -17,9 +17,9 @@ module Wreq # native conversion, such as TypeError or Wreq::BuilderError. Request # validation finishes before network I/O. # - # Clients cannot be created or used in a child process forked after wreq-ruby - # was loaded. These operations raise Wreq::ForkError. Load wreq-ruby inside - # each worker after it has been forked. + # A child process cannot create or use a client if it inherited wreq-ruby + # from its parent. These calls raise Wreq::ForkError before accessing the + # native runtime. Require wreq after the worker has been forked. # # @example Basic usage # client = Wreq::Client.new @@ -70,31 +70,40 @@ class Client # used to store and retrieve cookies for all requests made by this # client. Typically used together with `cookie_store: true`. # - # @param timeout [Integer, nil] Overall timeout for the entire request + # @param timeout [Numeric, nil] Overall timeout for the entire request # in seconds, including connection establishment, request transmission, - # and response reading. If not set, requests may wait indefinitely. - # - # @param connect_timeout [Integer, nil] Maximum time in seconds to wait - # when establishing a connection to the remote server. This is separate - # from the overall timeout. - # - # @param read_timeout [Integer, nil] Maximum time in seconds to wait - # between reading chunks of data from the server. Applies to each - # read operation, not the entire response. - # - # @param tcp_keepalive [Integer, nil] Time in seconds that a connection - # must be idle before TCP keepalive probes are sent. Helps detect - # broken connections. - # - # @param tcp_keepalive_interval [Integer, nil] Time in seconds between - # individual TCP keepalive probes. Only relevant if tcp_keepalive is set. + # and response reading. Fractional seconds are accepted. The value must + # be finite and non-negative; 0 expires immediately. Nil or omission + # leaves the timeout unset. + # + # @param connect_timeout [Numeric, nil] Maximum time in seconds to wait + # when establishing a connection to the remote server. Fractional seconds + # are accepted. The value must be finite and non-negative; 0 expires + # immediately. Nil or omission leaves the timeout unset. + # + # @param read_timeout [Numeric, nil] Maximum time in seconds to wait + # between reading chunks of data from the server. Fractional seconds are + # accepted. The value must be finite and non-negative; 0 expires + # immediately. Nil or omission leaves the timeout unset. + # + # @param tcp_keepalive [Numeric, nil] Time in seconds that a connection + # must be idle before TCP keepalive probes are sent. Fractional seconds + # are accepted. The value must be finite and non-negative; 0 is passed + # through as a zero duration. Nil or omission leaves the option unset. + # + # @param tcp_keepalive_interval [Numeric, nil] Time in seconds between + # individual TCP keepalive probes. Fractional seconds are accepted. The + # value must be finite and non-negative; 0 is passed through as a zero + # duration. Nil or omission leaves the option unset. # # @param tcp_keepalive_retries [Integer, nil] Number of failed keepalive # probes before the connection is considered dead and closed. # - # @param tcp_user_timeout [Integer, nil] Maximum time in seconds that + # @param tcp_user_timeout [Numeric, nil] Maximum time in seconds that # transmitted data may remain unacknowledged before the connection is - # forcibly closed. Available on Android, Fuchsia, and Linux only. + # forcibly closed. Fractional seconds are accepted. The value must be + # finite and non-negative; 0 is passed through as a zero duration. Nil or + # omission leaves the option unset. Available on Android, Fuchsia, and Linux only. # # @param tcp_nodelay [Boolean, nil] Enable TCP_NODELAY socket option, # which disables Nagle's algorithm. When true, small packets are sent @@ -105,9 +114,10 @@ class Client # allowing the reuse of local addresses in TIME_WAIT state. Useful for # reducing port exhaustion in high-throughput scenarios. # - # @param pool_idle_timeout [Integer, nil] Time in seconds before idle - # connections in the pool are evicted and closed. Helps free up - # resources for long-running applications. + # @param pool_idle_timeout [Numeric, nil] Time in seconds before idle + # connections in the pool are evicted and closed. Fractional seconds are + # accepted. The value must be finite and non-negative; 0 expires idle + # entries immediately. Nil or omission leaves the timeout unset. # # @param pool_max_idle_per_host [Integer, nil] Maximum number of idle # connections to maintain per host in the connection pool. Connections @@ -169,7 +179,7 @@ class Client # value cannot be converted or validated. # @raise [Wreq::BuilderError, Wreq::TlsError] if the native client cannot # be initialized. - # @raise [Wreq::ForkError] if the process inherited wreq-ruby through fork. + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent # # @example Minimal client # client = Wreq::Client.new @@ -270,8 +280,12 @@ def self.new(**options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -285,6 +299,7 @@ def self.new(**options) # or unavailable on the current platform # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def request(method, url, **options) end @@ -305,8 +320,12 @@ def request(method, url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -318,6 +337,7 @@ def request(method, url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def get(url, **options) end @@ -338,8 +358,12 @@ def get(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -351,6 +375,7 @@ def get(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def head(url, **options) end @@ -371,8 +396,12 @@ def head(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -384,6 +413,7 @@ def head(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def post(url, **options) end @@ -404,8 +434,12 @@ def post(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -417,6 +451,7 @@ def post(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def put(url, **options) end @@ -437,8 +472,12 @@ def put(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -450,6 +489,7 @@ def put(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def delete(url, **options) end @@ -470,8 +510,12 @@ def delete(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -483,6 +527,7 @@ def delete(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def options(url, **options) end @@ -503,8 +548,12 @@ def options(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -516,6 +565,7 @@ def options(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def trace(url, **options) end @@ -536,8 +586,12 @@ def trace(url, **options) # @param brotli [Boolean, nil] Enable Brotli compression # @param deflate [Boolean, nil] Enable deflate compression # @param zstd [Boolean, nil] Enable Zstandard compression - # @param timeout [Integer, nil] Total request timeout (seconds) - # @param read_timeout [Integer, nil] Per-chunk read timeout (seconds) + # @param timeout [Numeric, nil] Total request timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. + # @param read_timeout [Numeric, nil] Per-chunk read timeout in seconds. + # Must be finite and non-negative; fractions are accepted, 0 expires + # immediately, and nil leaves it unset. # @param proxy [String, nil] Proxy server URI # @param local_address [String, nil] Bind the request's local source IP address (IPv4/IPv6). Useful on multi-homed hosts to originate connections from a specific address or enforce source routing. Examples: "192.168.1.10", "10.0.0.5", "2001:db8::1". The address must exist on the host and be routable or the connection may fail. # @param interface [String, nil] Bind to an interface on supported platforms; unsupported platforms raise ArgumentError. @@ -549,6 +603,7 @@ def trace(url, **options) # @return [Wreq::Response] HTTP response # @raise [TypeError, ArgumentError, Wreq::BuilderError] if a known option # value cannot be converted, validated, or built + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def patch(url, **options) end end diff --git a/lib/wreq_ruby/cookie.rb b/lib/wreq_ruby/cookie.rb index 1c70588..e13fdb7 100644 --- a/lib/wreq_ruby/cookie.rb +++ b/lib/wreq_ruby/cookie.rb @@ -1,8 +1,9 @@ unless defined?(Wreq) module Wreq - # Cookie SameSite attribute. + # SameSite values for HTTP cookies. # - # Values follow the Rust enum exposed by the native extension. + # The constant names match the native Rust variants. + # standard:disable Naming/ConstantName class SameSite # Strict same-site policy. Strict = nil @@ -10,33 +11,60 @@ class SameSite Lax = nil # None same-site policy. None = nil + + # Returns the SameSite attribute name (e.g. "Strict", "Lax", "None"). + # @return [String] + def to_s + end + + # Returns the SameSite attribute as a lowercase symbol (e.g. :strict, :lax, :none). + # @return [Symbol] + def to_sym + end + + # Value-based equality. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end end + # standard:enable Naming/ConstantName - # A single HTTP cookie. + # A single HTTP cookie backed by an immutable native value. # - # Thread-safe: instances are backed by an immutable Rust value and can be - # shared across threads safely. This mirrors the native `Wreq::Cookie`. - # Constructor accepts `name`, `value`, plus optional keyword arguments for - # other attributes. + # Cookie instances can be shared safely between threads. Pass optional + # attributes as keywords to {.new}. class Cookie - # Create a new Cookie instance. + # Creates a Cookie instance. # - # Note: This matches the native binding which defines `new` (not `initialize`). + # The native extension defines `.new` directly instead of `#initialize`. # # @param name [String] Cookie name # @param value [String] Cookie value # @param options [Hash] Optional keyword arguments # @option options [String] :domain Domain attribute # @option options [String] :path Path attribute - # @option options [Integer] :max_age Signed Max-Age in seconds; zero or negative expires immediately - # @option options [Time, Numeric] :expires Expiration time or finite Unix timestamp + # @option options [Integer] :max_age Signed Max-Age in seconds. + # Zero or negative values expire the cookie immediately. + # @option options [Time, Numeric] :expires A Time or finite Unix timestamp in seconds # @option options [Boolean] :http_only HttpOnly flag # @option options [Boolean] :secure Secure flag # @option options [Wreq::SameSite] :same_site SameSite attribute # @return [Wreq::Cookie] - # @raise [ArgumentError] if an option is unknown, duplicated, or otherwise invalid - # @raise [RangeError] if Max-Age or the expiration is outside the supported range - # @raise [TypeError] if an option has an incompatible value type + # @raise [ArgumentError] if an option is unknown or duplicated, or if :expires is not finite + # @raise [RangeError] if :max_age or :expires is outside the supported range + # @raise [TypeError] if an option has the wrong type # @example # c = Wreq::Cookie.new( # "sid", "abc", @@ -89,6 +117,11 @@ def same_site_lax? def same_site_strict? end + # Returns the SameSite setting, or nil if it was omitted. + # @return [Wreq::SameSite, nil] + def same_site + end + # @return [String, nil] Path attribute def path end @@ -97,41 +130,50 @@ def path def domain end - # Returns the signed Max-Age in seconds. + # Returns the signed Max-Age lifetime in seconds. # - # Zero and negative values indicate immediate expiration. + # Zero and negative values expire the cookie immediately. # @return [Integer, nil] def max_age end - # Returns the expiration as a UTC Ruby Time. + # Returns the expiration time in UTC. # @return [Time, nil] def expires_at end - # Returns the expiration as fractional Unix seconds. - # @deprecated Use {#expires_at} for a Ruby-native time value. + # Returns the expiration as a Unix timestamp with fractional seconds. + # The Float return value may lose precision for large timestamps. + # @deprecated Use {#expires_at}, which returns Time. # @return [Float, nil] def expires end + + # Returns the cookie formatted as a Set-Cookie value. + # @return [String] + def to_s + end end - # A cookie store (jar) used by the client to manage cookies across requests. + # Stores cookies for reuse across requests. + # + # Pass a Jar to Wreq::Client as `cookie_provider` to share its cookies. class Jar - # Create a new, empty cookie jar. + # Creates an empty cookie jar. # @return [Wreq::Jar] def self.new end - # Get all cookies currently stored. + # Returns all stored cookies. # @return [Array] def get_all end - # Add a cookie from a Set-Cookie string for the given URL. - # @param cookie [String, Wreq::Cookie] A Set-Cookie string - # @param url [String] + # Adds a Cookie object or Set-Cookie string for the given URL. + # @param cookie [String, Wreq::Cookie] Cookie to store + # @param url [String] URL that scopes the cookie # @return [void] + # @raise [TypeError] if cookie is neither a String nor Wreq::Cookie def add(cookie, url) end @@ -152,6 +194,8 @@ def clear module Wreq class Cookie + # Returns a short representation for debugging. + # @return [String] def inspect parts = ["#" end diff --git a/lib/wreq_ruby/emulate.rb b/lib/wreq_ruby/emulate.rb index 545ed33..027ace3 100644 --- a/lib/wreq_ruby/emulate.rb +++ b/lib/wreq_ruby/emulate.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# Profile and platform constants mirror the native enum variant names. +# standard:disable Naming/ConstantName + module Wreq # Browser and client fingerprint profile enumeration backed by Rust. # @@ -163,6 +166,29 @@ class Profile def to_s end end + + unless method_defined?(:==) + # Value-based equality. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + end + + unless method_defined?(:eql?) + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + end + + unless method_defined?(:hash) + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end + end end # Operating system platform enumeration backed by Rust. @@ -194,6 +220,36 @@ class Platform def to_s end end + + unless method_defined?(:to_sym) + # Returns the platform as a lowercase symbol (e.g. :windows, :linux). + # @return [Symbol] + def to_sym + end + end + + unless method_defined?(:==) + # Value-based equality. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + end + + unless method_defined?(:eql?) + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + end + + unless method_defined?(:hash) + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end + end end # Emulation option wrapper. @@ -226,7 +282,7 @@ def to_s class Emulation # Native fields and methods are set by the extension. # This stub is for documentation only. - unless method_defined?(:new) + unless singleton_methods(false).include?(:new) # @param profile [Wreq::Profile, nil] Fingerprint profile to emulate # @param platform [Wreq::Platform, nil] Operating system platform to emulate # @param http2 [Boolean, nil] Whether HTTP/2 emulation is enabled; defaults @@ -241,3 +297,5 @@ def self.new(**options) end end end + +# standard:enable Naming/ConstantName diff --git a/lib/wreq_ruby/error.rb b/lib/wreq_ruby/error.rb index 745433c..a7eb353 100644 --- a/lib/wreq_ruby/error.rb +++ b/lib/wreq_ruby/error.rb @@ -7,16 +7,17 @@ module Wreq # Memory allocation failed. class MemoryError < StandardError; end - # The native extension was inherited from a parent process. + # The child process inherited wreq-ruby from its parent. # - # Raised when wreq-ruby is used in a child process forked after the - # extension was loaded. Its process-global native state cannot be reused - # safely in the child. + # Tokio worker threads do not survive fork, and inherited pooled + # connections are not safe to reuse. This error is raised before a child + # can access that state. # # @example # Process.fork do - # Wreq::Client.new # Raises when the parent loaded wreq-ruby. + # Wreq::Client.new # Raises if the parent loaded wreq-ruby. # end + # @see https://github.com/SearchApi/wreq-ruby/blob/main/docs/fork-safety.md class ForkError < RuntimeError; end # Network connection errors diff --git a/lib/wreq_ruby/http.rb b/lib/wreq_ruby/http.rb index 443f5eb..2bf701e 100644 --- a/lib/wreq_ruby/http.rb +++ b/lib/wreq_ruby/http.rb @@ -25,6 +25,43 @@ class Method TRACE = nil # @return [Wreq::Method] HTTP TRACE method PATCH = nil # @return [Wreq::Method] HTTP PATCH method end + + # Returns the HTTP method token (e.g. "GET", "POST"). + # @return [String] + unless method_defined?(:to_s) + def to_s + end + end + + # Returns the HTTP method as a lowercase symbol (e.g. :get, :post). + # @return [Symbol] + unless method_defined?(:to_sym) + def to_sym + end + end + + # Value-based equality. Returns true when both represent the same HTTP method. + # @param other [Object] + # @return [Boolean] + unless method_defined?(:==) + def ==(other) + end + end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + unless method_defined?(:eql?) + def eql?(other) + end + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + unless method_defined?(:hash) + def hash + end + end end # HTTP version enumeration backed by Rust. @@ -63,6 +100,21 @@ def to_s def ==(other) end end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + unless method_defined?(:eql?) + def eql?(other) + end + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + unless method_defined?(:hash) + def hash + end + end end # HTTP status code wrapper. @@ -141,6 +193,28 @@ def server_error? # @return [String] Status code as string def to_s end + + # Returns the status code as an integer. + # @return [Integer] the numeric HTTP status code + def to_i + end + + # Value-based equality. Only compares with other StatusCode instances. + # @param other [Object] + # @return [Boolean] + def ==(other) + end + + # Strict equality for Hash key and Set member semantics. + # @param other [Object] + # @return [Boolean] + def eql?(other) + end + + # Hash value consistent with {#eql?} for use as Hash keys. + # @return [Integer] + def hash + end end end end diff --git a/lib/wreq_ruby/response.rb b/lib/wreq_ruby/response.rb index fe816fa..8a497db 100644 --- a/lib/wreq_ruby/response.rb +++ b/lib/wreq_ruby/response.rb @@ -8,8 +8,8 @@ module Wreq # access to HTTP response data including status codes, headers, body # content, and streaming capabilities. # - # Reading or streaming the body raises Wreq::ForkError in a child process - # forked after wreq-ruby was loaded. + # Body methods raise Wreq::ForkError if the child inherited wreq-ruby from + # its parent. # # @example Basic response handling # response = client.get("https://api.example.com") @@ -110,6 +110,7 @@ def cookies # Get the response bytes as a binary string. # @return [String] Response body as binary data + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent # @example # binary_data = response.bytes # puts binary_data.size # => 1024 @@ -125,6 +126,7 @@ def bytes # html = response.text("ISO-8859-1") # puts html # @raise [Wreq::DecodingError] if body cannot be decoded with the specified encoding + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent def text(default_encoding = "UTF-8") end @@ -135,6 +137,7 @@ def text(default_encoding = "UTF-8") # # @return [Object] Parsed JSON (Hash, Array, String, Integer, Float, Boolean, nil) # @raise [Wreq::DecodingError] if body is not valid JSON + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent # @example # data = response.json # puts data["key"] @@ -152,6 +155,7 @@ def json # @raise [LocalJumpError] if called without a block # @raise [Wreq::TimeoutError, Wreq::BodyError, Wreq::ConnectionResetError, Wreq::RequestError] # if streaming fails while reading the response body + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent # @example Save response to file # File.open("output.bin", "wb") do |f| # response.chunks { |chunk| f.write(chunk) } @@ -168,6 +172,7 @@ def chunks # Close the response and free associated resources. # # @return [void] + # @raise [Wreq::ForkError] if the child inherited wreq-ruby from its parent # @example # response.close def close diff --git a/src/arch.rs b/src/arch.rs index 18cb2a4..91d542b 100644 --- a/src/arch.rs +++ b/src/arch.rs @@ -6,6 +6,49 @@ //! do not leak into the rest of the binding. #![allow(unsafe_code)] +use std::mem::ManuallyDrop; + +/// Native state that belongs to the process where the extension was loaded. +/// +/// A forked child must not destroy inherited clients, channels, or response +/// bodies because their synchronization state may belong to threads that no +/// longer exist. The child intentionally leaks the value and lets the operating +/// system reclaim it when the process exits. +/// +/// This wrapper only controls destruction. Call [`crate::rt::ensure_current`] +/// before accessing the inner value. +#[derive(Clone)] +pub(crate) struct ProcessLocal(ManuallyDrop); + +impl ProcessLocal { + /// Wrap native state created by the current process. + pub(crate) fn new(value: T) -> Self { + Self(ManuallyDrop::new(value)) + } +} + +impl AsRef for ProcessLocal { + fn as_ref(&self) -> &T { + &self.0 + } +} + +impl Drop for ProcessLocal { + fn drop(&mut self) { + #[cfg(unix)] + if forked_process_ids().is_some() { + return; + } + + // SAFETY: `new` initializes the value exactly once, `ManuallyDrop` + // prevents an automatic second drop, and this wrapper's `Drop` + // implementation runs at most once. + unsafe { + ManuallyDrop::drop(&mut self.0); + } + } +} + /// Whether the native client exposes TCP user-timeout configuration. pub(crate) const SUPPORTS_TCP_USER_TIMEOUT: bool = cfg!(any( target_os = "android", @@ -111,3 +154,30 @@ mod windows_gnu { } } } + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::ProcessLocal; + + struct DropCounter<'a>(&'a Cell); + + impl Drop for DropCounter<'_> { + fn drop(&mut self) { + self.0.set(self.0.get() + 1); + } + } + + #[test] + fn process_local_drops_in_its_owner_process() { + let drops = Cell::new(0); + + { + let value = ProcessLocal::new(DropCounter(&drops)); + assert_eq!(value.as_ref().0.get(), 0); + } + + assert_eq!(drops.get(), 1); + } +} diff --git a/src/client.rs b/src/client.rs index fc4dd6e..ce1c781 100644 --- a/src/client.rs +++ b/src/client.rs @@ -4,14 +4,14 @@ mod query; mod req; pub mod resp; -use std::{net::IpAddr, time::Duration}; +use std::net::IpAddr; use ::serde::Deserialize; use magnus::{Module, Object, RModule, Ruby, TryConvert, Value, function, method, typed_data::Obj}; use wreq::Proxy; use crate::{ - arch::{SUPPORTS_INTERFACE, SUPPORTS_TCP_USER_TIMEOUT}, + arch::{ProcessLocal, SUPPORTS_INTERFACE, SUPPORTS_TCP_USER_TIMEOUT}, client::{req::execute_request, resp::Response}, cookie::Jar, emulate::Emulation, @@ -22,6 +22,7 @@ use crate::{ http::Method, options::{NativeOption, Options}, rt, + time::Duration, }; /// A builder for `Client`. @@ -54,31 +55,38 @@ struct Builder { cookie_provider: NativeOption, // ========= Timeout options ========= - /// The timeout to use for the client. (in seconds) - timeout: Option, - /// The connect timeout to use for the client. (in seconds) - connect_timeout: Option, - /// The read timeout to use for the client. (in seconds) - read_timeout: Option, + /// Overall timeout for a request, including connection and response body. + #[serde(default)] + timeout: NativeOption, + /// Maximum duration allowed to establish a connection. + #[serde(default)] + connect_timeout: NativeOption, + /// Maximum idle duration between response body reads. + #[serde(default)] + read_timeout: NativeOption, // ========= TCP options ========= - /// Set that all sockets have `SO_KEEPALIVE` set with the supplied duration. (in seconds) - tcp_keepalive: Option, - /// Set the interval between TCP keepalive probes. (in seconds) - tcp_keepalive_interval: Option, + /// Idle duration before TCP keepalive probes begin. + #[serde(default)] + tcp_keepalive: NativeOption, + /// Interval between TCP keepalive probes. + #[serde(default)] + tcp_keepalive_interval: NativeOption, /// Set the number of retries for TCP keepalive. tcp_keepalive_retries: Option, - /// Set an optional user timeout for TCP sockets. (in seconds) + /// Maximum duration for which transmitted data may remain unacknowledged. + #[serde(default)] #[allow(dead_code)] - tcp_user_timeout: Option, + tcp_user_timeout: NativeOption, /// Set that all sockets have `NO_DELAY` set. tcp_nodelay: Option, /// Set that all sockets have `SO_REUSEADDR` set. tcp_reuse_address: Option, // ========= Connection pool options ========= - /// Set an optional timeout for idle sockets being kept-alive. (in seconds) - pool_idle_timeout: Option, + /// Maximum idle duration before a pooled connection is evicted. + #[serde(default)] + pool_idle_timeout: NativeOption, /// Sets the maximum idle connection per host allowed in the pool. pool_max_idle_per_host: Option, /// Sets the maximum number of connections in the pool. @@ -121,7 +129,7 @@ struct Builder { #[derive(Clone)] #[magnus::wrap(class = "Wreq::Client", free_immediately, size)] -pub struct Client(wreq::Client); +pub struct Client(ProcessLocal); // ===== impl Builder ===== @@ -175,6 +183,16 @@ impl Builder { cookie_provider, Obj => |value| (*value).clone() ); + + // Duration options. + extract_native_option!(options, builder, timeout); + extract_native_option!(options, builder, connect_timeout); + extract_native_option!(options, builder, read_timeout); + extract_native_option!(options, builder, tcp_keepalive); + extract_native_option!(options, builder, tcp_keepalive_interval); + extract_native_option!(options, builder, tcp_user_timeout); + extract_native_option!(options, builder, pool_idle_timeout); + builder .proxy .set(Extractor::::try_convert(options.as_value())?.into_inner()); @@ -194,11 +212,15 @@ impl Client { /// native fallible client builder. Extra positional arguments return /// `ArgumentError`. pub fn new(ruby: &Ruby, args: &[Value]) -> Result { + rt::ensure_current(ruby)?; + Options::from_args(ruby, args, "client")? .map(Builder::from_options) .transpose() .map(Option::unwrap_or_default) .and_then(|params| Self::build(ruby, params)) + .map(ProcessLocal::new) + .map(Self) } /// Build the default client through the same fallible path as `new`. @@ -207,7 +229,7 @@ impl Client { /// /// Returns `Wreq::BuilderError`, `Wreq::TlsError`, or another mapped native /// initialization error without unwinding through Ruby. - pub(crate) fn default_client(ruby: &Ruby) -> Result { + pub(crate) fn default_client(ruby: &Ruby) -> Result { Self::build(ruby, Builder::default()) } @@ -218,7 +240,7 @@ impl Client { /// Returns `Wreq::ForkError` before touching native client state when the /// extension was inherited from a parent process. Maps native build /// failures only after the GVL has been reacquired. - fn build(ruby: &Ruby, mut params: Builder) -> Result { + fn build(ruby: &Ruby, mut params: Builder) -> Result { rt::ensure_current(ruby)?; let result = gvl::nogvl(|| { @@ -273,18 +295,16 @@ impl Client { // TCP options. apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_keepalive, - tcp_keepalive, - Duration::from_secs + tcp_keepalive ); apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_keepalive_interval, - tcp_keepalive_interval, - Duration::from_secs + tcp_keepalive_interval ); apply_option!( set_if_some, @@ -294,11 +314,10 @@ impl Client { ); #[cfg(any(target_os = "android", target_os = "fuchsia", target_os = "linux"))] apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.tcp_user_timeout, - tcp_user_timeout, - Duration::from_secs + tcp_user_timeout ); apply_option!(set_if_some, builder, params.tcp_nodelay, tcp_nodelay); apply_option!( @@ -309,35 +328,26 @@ impl Client { ); // Timeout options. + apply_option!(set_if_some_inner, builder, params.timeout, timeout); apply_option!( - set_if_some_map, - builder, - params.timeout, - timeout, - Duration::from_secs - ); - apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.connect_timeout, - connect_timeout, - Duration::from_secs + connect_timeout ); apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.read_timeout, - read_timeout, - Duration::from_secs + read_timeout ); // Pool options. apply_option!( - set_if_some_map, + set_if_some_inner, builder, params.pool_idle_timeout, - pool_idle_timeout, - Duration::from_secs + pool_idle_timeout ); apply_option!( set_if_some, @@ -379,12 +389,17 @@ impl Client { apply_option!(set_if_some, builder, params.deflate, deflate); apply_option!(set_if_some, builder, params.zstd, zstd); - builder.build().map(Client) + builder.build() }); // Ruby exceptions must be created after the GVL has been reacquired. result.map_err(|err| wreq_error(ruby, err)) } + + /// Clone the native client handle for a request future. + fn native_client(&self) -> wreq::Client { + self.0.as_ref().clone() + } } impl Client { @@ -396,9 +411,9 @@ impl Client { ruby: &Ruby, args: &[Value], ) -> Result { - let ((method, url), request) = extract_request!(args, (Obj, String)); + let ((method, url), request) = extract_request!(ruby, args, (Obj, String)); let client = Self::default_client(ruby)?; - execute_request(ruby, client.0, *method, url, request) + execute_request(ruby, client, *method, url, request) } /// Send a request with `method` through a newly built default client. @@ -410,72 +425,72 @@ impl Client { method: Method, args: &[Value], ) -> Result { - let ((url,), request) = extract_request!(args, (String,)); + let ((url,), request) = extract_request!(ruby, args, (String,)); let client = Self::default_client(ruby)?; - execute_request(ruby, client.0, method, url, request) + execute_request(ruby, client, method, url, request) } /// Send a HTTP request. #[inline] pub fn request(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { - let ((method, url), request) = extract_request!(args, (Obj, String)); - execute_request(ruby, rb_self.0.clone(), *method, url, request) + let ((method, url), request) = extract_request!(ruby, args, (Obj, String)); + execute_request(ruby, rb_self.native_client(), *method, url, request) } /// Send a GET request. #[inline] pub fn get(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { - let ((url,), request) = extract_request!(args, (String,)); - execute_request(ruby, rb_self.0.clone(), Method::GET, url, request) + let ((url,), request) = extract_request!(ruby, args, (String,)); + execute_request(ruby, rb_self.native_client(), Method::GET, url, request) } /// Send a POST request. #[inline] pub fn post(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { - let ((url,), request) = extract_request!(args, (String,)); - execute_request(ruby, rb_self.0.clone(), Method::POST, url, request) + let ((url,), request) = extract_request!(ruby, args, (String,)); + execute_request(ruby, rb_self.native_client(), Method::POST, url, request) } /// Send a PUT request. #[inline] pub fn put(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { - let ((url,), request) = extract_request!(args, (String,)); - execute_request(ruby, rb_self.0.clone(), Method::PUT, url, request) + let ((url,), request) = extract_request!(ruby, args, (String,)); + execute_request(ruby, rb_self.native_client(), Method::PUT, url, request) } /// Send a DELETE request. #[inline] pub fn delete(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { - let ((url,), request) = extract_request!(args, (String,)); - execute_request(ruby, rb_self.0.clone(), Method::DELETE, url, request) + let ((url,), request) = extract_request!(ruby, args, (String,)); + execute_request(ruby, rb_self.native_client(), Method::DELETE, url, request) } /// Send a HEAD request. #[inline] pub fn head(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { - let ((url,), request) = extract_request!(args, (String,)); - execute_request(ruby, rb_self.0.clone(), Method::HEAD, url, request) + let ((url,), request) = extract_request!(ruby, args, (String,)); + execute_request(ruby, rb_self.native_client(), Method::HEAD, url, request) } /// Send an OPTIONS request. #[inline] pub fn options(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { - let ((url,), request) = extract_request!(args, (String,)); - execute_request(ruby, rb_self.0.clone(), Method::OPTIONS, url, request) + let ((url,), request) = extract_request!(ruby, args, (String,)); + execute_request(ruby, rb_self.native_client(), Method::OPTIONS, url, request) } /// Send a TRACE request. #[inline] pub fn trace(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { - let ((url,), request) = extract_request!(args, (String,)); - execute_request(ruby, rb_self.0.clone(), Method::TRACE, url, request) + let ((url,), request) = extract_request!(ruby, args, (String,)); + execute_request(ruby, rb_self.native_client(), Method::TRACE, url, request) } /// Send a PATCH request. #[inline] pub fn patch(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { - let ((url,), request) = extract_request!(args, (String,)); - execute_request(ruby, rb_self.0.clone(), Method::PATCH, url, request) + let ((url,), request) = extract_request!(ruby, args, (String,)); + execute_request(ruby, rb_self.native_client(), Method::PATCH, url, request) } } diff --git a/src/client/body/stream.rs b/src/client/body/stream.rs index 88c194b..5a210d0 100644 --- a/src/client/body/stream.rs +++ b/src/client/body/stream.rs @@ -13,6 +13,7 @@ use magnus::{Error, Integer, RString, Ruby, Value, scan_args::scan_args}; use tokio::sync::{Mutex, Semaphore, mpsc}; use crate::{ + arch::ProcessLocal, error::{ argument_error, body_sender_borrow_error, body_sender_borrow_mut_error, body_sender_send_error, closed_body_sender_error, memory_error, type_error, wreq_error, @@ -33,7 +34,7 @@ pub struct BodyReceiver(Mutex> + S /// receiver. Ruby's GVL protects state access; no [`RefCell`] borrow is kept /// while request backpressure waits without the GVL. #[magnus::wrap(class = "Wreq::BodySender", free_immediately, size)] -pub struct BodySender(RefCell); +pub struct BodySender(ProcessLocal>); /// Mutable ownership state for both halves of the body channel. struct InnerBodySender { @@ -89,8 +90,10 @@ impl BodySender { /// # Errors /// /// Returns `TypeError` for a non-Integer capacity and `ArgumentError` for - /// an invalid range or argument count. + /// an invalid range or argument count. Returns `Wreq::ForkError` before + /// creating a channel in a child that inherited the extension. pub fn new(ruby: &Ruby, args: &[Value]) -> Result { + rt::ensure_current(ruby)?; let capacity = parse_capacity(ruby, args)?; // Create the Tokio channel without allowing an unwind to cross the Ruby FFI boundary. @@ -100,10 +103,12 @@ impl BodySender { let (tx, rx) = catch_unwind(|| mpsc::channel(capacity)).map_err(|_| invalid_capacity_error(ruby))?; - Ok(BodySender(RefCell::new(InnerBodySender { - tx: Some(tx), - rx: Some(rx), - }))) + Ok(BodySender(ProcessLocal::new(RefCell::new( + InnerBodySender { + tx: Some(tx), + rx: Some(rx), + }, + )))) } /// Push a binary chunk, waiting for capacity when the channel is full. @@ -113,8 +118,11 @@ impl BodySender { /// # Errors /// /// Returns `IOError` after either channel side has closed. An interrupted - /// wait raises Ruby's standard `Interrupt` exception. + /// wait raises Ruby's standard `Interrupt` exception. Returns + /// `Wreq::ForkError` before reading an inherited channel. pub fn push(ruby: &Ruby, rb_self: &Self, data: RString) -> Result<(), Error> { + rt::ensure_current(ruby)?; + // Clone during the shared borrow, then release it before waiting // for capacity. Request attachment needs a mutable borrow. let tx = match &rb_self.read_inner(ruby)?.tx { @@ -131,8 +139,10 @@ impl BodySender { /// /// # Errors /// - /// Returns `Wreq::BodyError` if the internal state is already borrowed. + /// Returns `Wreq::ForkError` before reading an inherited channel, or + /// `Wreq::BodyError` if the internal state is already borrowed. pub fn close(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> { + rt::ensure_current(ruby)?; let mut inner = rb_self.write_inner(ruby)?; inner.tx.take(); Ok(()) @@ -142,14 +152,17 @@ impl BodySender { /// /// # Errors /// - /// Returns `Wreq::BodyError` if the internal state is already borrowed. + /// Returns `Wreq::ForkError` before reading an inherited channel, or + /// `Wreq::BodyError` if the internal state is already borrowed. pub fn is_closed(ruby: &Ruby, rb_self: &Self) -> Result { + rt::ensure_current(ruby)?; rb_self.read_inner(ruby).map(|r| r.is_closed()) } /// Borrow the channel state without panicking on accidental re-entry. fn read_inner(&self, ruby: &Ruby) -> Result, Error> { self.0 + .as_ref() .try_borrow() .map_err(|err| body_sender_borrow_error(ruby, err)) } @@ -157,6 +170,7 @@ impl BodySender { /// Mutably borrow the channel state without panicking on accidental re-entry. fn write_inner(&self, ruby: &Ruby) -> Result, Error> { self.0 + .as_ref() .try_borrow_mut() .map_err(|err| body_sender_borrow_mut_error(ruby, err)) } @@ -168,6 +182,7 @@ impl BodySender { /// Returns `Wreq::MemoryError` if the receiver was already consumed, or /// `Wreq::BodyError` if Ruby re-enters while the state is borrowed. pub(super) fn take_receiver(&self, ruby: &Ruby) -> Result, Error> { + rt::ensure_current(ruby)?; self.write_inner(ruby)? .rx .take() diff --git a/src/client/req.rs b/src/client/req.rs index 0603d9e..353bb63 100644 --- a/src/client/req.rs +++ b/src/client/req.rs @@ -1,4 +1,4 @@ -use std::{net::IpAddr, time::Duration}; +use std::net::IpAddr; use ::serde::Deserialize; use http::header; @@ -17,6 +17,7 @@ use crate::{ http::{Method, Version}, options::{NativeOption, Options}, rt, + time::Duration, }; /// The parameters for a request. @@ -38,11 +39,13 @@ pub struct Request { #[allow(dead_code)] interface: Option, - /// The timeout to use for the request. - timeout: Option, + /// Overall timeout for this request, overriding the client default. + #[serde(default)] + timeout: NativeOption, - /// The read timeout to use for the request. - read_timeout: Option, + /// Maximum idle duration between body reads, overriding the client default. + #[serde(default)] + read_timeout: NativeOption, /// The HTTP version to use for the request. #[serde(default)] @@ -133,6 +136,8 @@ impl Request { Obj => |value| (*value).clone() ); extract_native_option!(options, builder, version); + extract_native_option!(options, builder, timeout); + extract_native_option!(options, builder, read_timeout); extract_native_option!(options, builder, headers); extract_native_option!(options, builder, orig_headers); extract_native_option!(options, builder, cookies); @@ -202,19 +207,12 @@ pub fn execute_request>( ); // Timeout options. + apply_option!(set_if_some_inner, builder, request.timeout, timeout); apply_option!( - set_if_some_map, - builder, - request.timeout, - timeout, - Duration::from_secs - ); - apply_option!( - set_if_some_map, + set_if_some_inner, builder, request.read_timeout, - read_timeout, - Duration::from_secs + read_timeout ); // Network options. diff --git a/src/client/resp.rs b/src/client/resp.rs index 10b3811..9cc2f98 100644 --- a/src/client/resp.rs +++ b/src/client/resp.rs @@ -9,6 +9,7 @@ use magnus::{Error, Module, RArray, RModule, Ruby, Value, scan_args::scan_args}; use wreq::Uri; use crate::{ + arch::ProcessLocal, client::body::{json::Json, stream::BodyReceiver}, cookie::Cookie, error::{memory_error, no_block_given_error, wreq_error}, @@ -28,8 +29,7 @@ pub struct Response { headers: HeaderMap, local_addr: Option, remote_addr: Option, - body: ArcSwapOption, - extensions: Extensions, + state: ProcessLocal, } /// Represents the state of the HTTP response body. @@ -40,6 +40,12 @@ enum Body { Reusable(Bytes), } +/// Response state that may contain handles owned by the native runtime. +struct NativeResponseState { + body: ArcSwapOption, + extensions: Extensions, +} + impl Response { /// Create a new [`Response`] instance. pub fn new(response: wreq::Response) -> Self { @@ -55,28 +61,31 @@ impl Response { local_addr, remote_addr, content_length, - extensions: parts.extensions, version: Version::from_ffi(parts.version), status: StatusCode::from(parts.status), headers: parts.headers, - body: ArcSwapOption::from_pointee(Body::Streamable(body)), + state: ProcessLocal::new(NativeResponseState { + body: ArcSwapOption::from_pointee(Body::Streamable(body)), + extensions: parts.extensions, + }), } } /// Internal method to get the wreq::Response, optionally streaming the body. fn response(&self, ruby: &Ruby, stream: bool) -> Result { rt::ensure_current(ruby)?; + let state = self.state.as_ref(); let build_response = |body: wreq::Body| -> wreq::Response { let mut response = HttpResponse::new(body); *response.version_mut() = self.version.into_ffi(); *response.status_mut() = self.status.0; *response.headers_mut() = self.headers.clone(); - *response.extensions_mut() = self.extensions.clone(); + *response.extensions_mut() = state.extensions.clone(); wreq::Response::from(response) }; - if let Some(arc) = self.body.swap(None) { + if let Some(arc) = state.body.swap(None) { match Arc::try_unwrap(arc) { Ok(Body::Streamable(body)) => { return if stream { @@ -88,14 +97,16 @@ impl Response { wreq_error, )?; - self.body + state + .body .store(Some(Arc::new(Body::Reusable(bytes.clone())))); Ok(build_response(wreq::Body::from(bytes))) }; } Ok(Body::Reusable(bytes)) => { - self.body + state + .body .store(Some(Arc::new(Body::Reusable(bytes.clone())))); if !stream { @@ -177,6 +188,7 @@ impl Response { /// Get the full response text given a specific encoding. pub fn text(ruby: &Ruby, rb_self: &Self, args: &[Value]) -> Result { + rt::ensure_current(ruby)?; let args = scan_args::<(), (Option,), (), (), (), ()>(args)?; let response = rb_self.response(ruby, false)?; match args.optional.0 { @@ -196,6 +208,8 @@ impl Response { /// Yield response body chunks to the given Ruby block. pub fn chunks(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> { + rt::ensure_current(ruby)?; + if !ruby.block_given() { return Err(no_block_given_error(ruby)); } @@ -213,16 +227,15 @@ impl Response { } /// Close the response body, dropping any resources. - #[inline] - pub fn close(&self) { - gvl::nogvl(|| self.body.swap(None)); - } -} - -impl Drop for Response { - fn drop(&mut self) { - // Ensure body is dropped in GVL - self.body.swap(None); + /// + /// # Errors + /// + /// Returns `Wreq::ForkError` before touching a body inherited from the + /// parent process. + pub fn close(ruby: &Ruby, rb_self: &Self) -> Result<(), Error> { + rt::ensure_current(ruby)?; + gvl::nogvl(|| rb_self.state.as_ref().body.swap(None)); + Ok(()) } } diff --git a/src/cookie.rs b/src/cookie.rs index 3823297..fdb16f6 100644 --- a/src/cookie.rs +++ b/src/cookie.rs @@ -1,8 +1,8 @@ //! Ruby bindings for HTTP cookies and the shared cookie jar. //! -//! Cookie expiration follows [RFC 6265]: Ruby `Time` values and finite Unix -//! timestamps are converted into signed native date-times, while non-positive -//! Max-Age values represent immediate expiration. +//! Expiration follows [RFC 6265]. The binding accepts Ruby `Time` values and +//! finite Unix timestamps without converting them through unsigned +//! `SystemTime`. A non-positive Max-Age expires the cookie immediately. //! //! [RFC 6265]: https://www.rfc-editor.org/rfc/rfc6265.html @@ -25,15 +25,17 @@ use crate::{ use self::helper::{CookieExpiration, to_ruby_time, to_unix_timestamp}; +// Defines constant registration, `into_ffi`/`from_ffi`, and handlers for +// Ruby's `to_s`, `to_sym`, `==`, `eql?`, and `hash` methods. define_ruby_enum!( /// The Cookie SameSite attribute. - const, SameSite, "Wreq::SameSite", cookie::SameSite, - Strict, - Lax, - None, + symbols: + Strict => "strict", + Lax => "lax", + None => "none", ); /// A single HTTP cookie. @@ -45,7 +47,7 @@ pub struct Cookie(RawCookie<'static>); #[derive(Default)] pub struct Cookies(pub Vec); -/// Keyword attributes used to build a [`Cookie`]. +/// Keyword options accepted by `Wreq::Cookie.new`. #[derive(Deserialize)] struct Builder { /// The Domain attribute. @@ -54,11 +56,11 @@ struct Builder { /// The Path attribute. path: Option, - /// The signed Max-Age in seconds. + /// The signed Max-Age lifetime in seconds. #[serde(default)] max_age: NativeOption, - /// The absolute expiration accepted through Magnus conversion. + /// The expiration converted from a Ruby `Time` or Numeric value. #[serde(default)] expires: NativeOption, @@ -73,11 +75,9 @@ struct Builder { same_site: NativeOption>, } -/// A good default `CookieStore` implementation. +/// A cookie jar that can be shared with a Ruby `Wreq::Client`. /// -/// This is the implementation used when simply calling `cookie_store(true)`. -/// This type is exposed to allow creating one and filling it with some -/// existing cookies more easily, before creating a `Client`. +/// Pass a populated jar as the client's `cookie_provider` option. #[derive(Clone, Default)] #[magnus::wrap(class = "Wreq::Jar", free_immediately, size)] pub struct Jar(pub Arc); @@ -85,12 +85,12 @@ pub struct Jar(pub Arc); // ===== impl Builder ===== impl Builder { - /// Deserialize and convert one validated Cookie attribute Hash. + /// Validates and deserializes a Ruby keyword options hash. /// /// # Errors /// - /// Returns `ArgumentError` for unknown or duplicate attributes and retains - /// the Ruby conversion error for invalid values. + /// Returns `ArgumentError` for unknown or duplicate keys. Invalid values + /// return the Ruby conversion error with the option name attached. fn from_options(options: Options<'_>) -> Result { let mut builder = options.validate_keys::()?.deserialize::()?; extract_native_option!(options, builder, max_age); @@ -99,7 +99,7 @@ impl Builder { Ok(builder) } - /// Build a native Cookie from its required identity and optional attributes. + /// Builds a native cookie from its name, value, and optional attributes. fn build(mut self, name: String, value: String) -> Cookie { let mut cookie = RawCookie::new(name, value); @@ -133,12 +133,12 @@ impl Builder { // ===== Ruby Cookie API ===== impl Cookie { - /// Create a new [`Cookie`] from a name, value, and keyword attributes. + /// Creates a [`Cookie`] from its name, value, and keyword options. /// /// # Errors /// - /// Returns `ArgumentError` for unknown or duplicate attributes and retains - /// the Ruby conversion error for invalid values. + /// Returns `ArgumentError` for unknown or duplicate keys. Invalid values + /// return the Ruby conversion error with the option name attached. pub fn new(ruby: &Ruby, args: &[Value]) -> Result { let args = magnus::scan_args::scan_args::<(String, String), (), (), (), RHash, ()>(args)?; let (name, value) = args.required; @@ -170,18 +170,24 @@ impl Cookie { self.0.secure().unwrap_or(false) } - /// Return whether the SameSite directive is Lax. + /// Returns true when the SameSite setting is Lax. #[inline] pub fn same_site_lax(&self) -> bool { self.0.same_site() == Some(cookie::SameSite::Lax) } - /// Return whether the SameSite directive is Strict. + /// Returns true when the SameSite setting is Strict. #[inline] pub fn same_site_strict(&self) -> bool { self.0.same_site() == Some(cookie::SameSite::Strict) } + /// Returns the SameSite setting, if present. + #[inline] + pub fn same_site(&self) -> Option { + self.0.same_site().map(SameSite::from_ffi) + } + /// Returns the path directive of the cookie, if set. #[inline] pub fn path(&self) -> Option<&str> { @@ -194,13 +200,13 @@ impl Cookie { self.0.domain() } - /// Return the signed Max-Age in seconds. + /// Returns the signed Max-Age lifetime in seconds. #[inline] pub fn max_age(&self) -> Option { self.0.max_age().map(|d| d.whole_seconds()) } - /// Return the cookie expiration as a UTC Ruby `Time`. + /// Returns the cookie expiration as a UTC Ruby `Time`. pub fn expires_at(ruby: &Ruby, rb_self: &Self) -> Result, Error> { rb_self .0 @@ -209,11 +215,17 @@ impl Cookie { .transpose() } - /// Return the cookie expiration as legacy fractional Unix seconds. + /// Returns the cookie expiration as fractional Unix seconds. #[inline] pub fn expires(&self) -> Option { self.0.expires_datetime().map(to_unix_timestamp) } + + /// Formats the cookie as a Set-Cookie value. + #[inline] + pub fn to_s(&self) -> String { + self.to_string() + } } // ===== Native Cookie helpers ===== @@ -221,10 +233,10 @@ impl Cookie { impl Cookie { /// Clone this cookie for insertion into the native jar. /// - /// [RFC 6265 section 5.2.2] treats a non-positive Max-Age as immediate - /// expiration. The native jar currently recognizes zero, so a negative - /// value is normalized only in this insertion clone; the Ruby cookie keeps - /// its original signed value. + /// [RFC 6265 section 5.2.2] says that zero or a negative Max-Age expires a + /// cookie immediately. The native jar recognizes zero for deletion, so + /// this clone maps negative values to zero before insertion. The Ruby + /// object keeps its original signed value. /// /// [RFC 6265 section 5.2.2]: https://www.rfc-editor.org/rfc/rfc6265.html#section-5.2.2 fn clone_for_jar(&self) -> RawCookie<'static> { @@ -316,14 +328,21 @@ impl Jar { } /// Add a cookie to this jar. - pub fn add(&self, cookie: Value, url: String) { + /// + /// # Errors + /// + /// Returns `TypeError` when `cookie` is neither a [`Cookie`] nor a String. + pub fn add(&self, cookie: Value, url: String) -> Result<(), Error> { if let Ok(cookie) = Obj::::try_convert(cookie) { - return gvl::nogvl(|| self.0.add(cookie.clone_for_jar(), &url)); + gvl::nogvl(|| self.0.add(cookie.clone_for_jar(), &url)); + return Ok(()); } - if let Ok(cookie_str) = String::try_convert(cookie) { - gvl::nogvl(|| self.0.add(cookie_str.as_ref(), &url)) - } + let ruby = Ruby::get_with(cookie); + let cookie = String::try_convert(cookie) + .map_err(|_| type_error(&ruby, "cookie must be a Wreq::Cookie or String"))?; + gvl::nogvl(|| self.0.add(cookie.as_ref(), &url)); + Ok(()) } /// Remove a cookie from this jar by name and URL. @@ -338,7 +357,7 @@ impl Jar { } mod helper { - //! Ruby time conversion helpers for `Wreq::Cookie`. + //! Converts between Ruby time values and native cookie timestamps. use cookie::time::{Duration, OffsetDateTime, error::ComponentRange}; use magnus::{ @@ -348,15 +367,15 @@ mod helper { use crate::error::{argument_error, range_error}; - /// A validated cookie expiration accepted from Ruby. + /// A validated cookie expiration converted from Ruby. /// - /// Ruby `Time` values retain nanosecond resolution. Integer timestamps avoid a - /// floating-point conversion, while other Numeric values are accepted as - /// finite fractional Unix timestamps. + /// Ruby `Time` values keep nanosecond precision. Integer timestamps are + /// converted directly; other Numeric values use finite fractional Unix + /// seconds. pub(super) struct CookieExpiration(OffsetDateTime); impl CookieExpiration { - /// Return the validated UTC expiration used by the native cookie. + /// Unwraps the validated UTC expiration. pub(super) fn into_inner(self) -> OffsetDateTime { self.0 } @@ -369,7 +388,7 @@ mod helper { } } - /// Convert a native cookie expiration into a UTC Ruby `Time`. + /// Converts a native cookie expiration into a UTC Ruby `Time`. pub(super) fn to_ruby_time(ruby: &Ruby, value: OffsetDateTime) -> Result { ruby.time_timespec_new( Timespec { @@ -380,12 +399,12 @@ mod helper { ) } - /// Convert a native cookie expiration into legacy fractional Unix seconds. + /// Converts a native cookie expiration into fractional Unix seconds. pub(super) fn to_unix_timestamp(value: OffsetDateTime) -> f64 { value.unix_timestamp() as f64 + f64::from(value.nanosecond()) / 1_000_000_000.0 } - /// Convert a supported Ruby expiration value into a native UTC date-time. + /// Converts a Ruby `Time` or Numeric value into a native UTC timestamp. fn expiration_from_value(ruby: &Ruby, value: Value) -> Result { if let Some(time) = Time::from_value(value) { return expiration_from_time(ruby, time); @@ -400,7 +419,7 @@ mod helper { expiration_from_float(ruby, f64::try_convert(value)?) } - /// Convert a Ruby `Time` without passing through unsigned `SystemTime` math. + /// Converts a Ruby `Time` from its signed timespec without using `SystemTime`. fn expiration_from_time(ruby: &Ruby, value: Time) -> Result { let timespec = value.timespec()?; let nanosecond = u32::try_from(timespec.tv_nsec) @@ -415,13 +434,13 @@ mod helper { .map_err(|error| expiration_range_error(ruby, error)) } - /// Convert exact signed Unix seconds into the native cookie time range. + /// Converts signed Unix seconds into the native cookie time range. fn expiration_from_seconds(ruby: &Ruby, seconds: i64) -> Result { OffsetDateTime::from_unix_timestamp(seconds) .map_err(|error| expiration_range_error(ruby, error)) } - /// Convert a finite fractional Unix timestamp without using a panicking API. + /// Converts a finite fractional Unix timestamp with checked arithmetic. fn expiration_from_float(ruby: &Ruby, seconds: f64) -> Result { if !seconds.is_finite() { return Err(argument_error(ruby, "timestamp must be finite")); @@ -434,7 +453,7 @@ mod helper { .ok_or_else(|| range_error(ruby, "timestamp is outside the supported range")) } - /// Map the native date-time range error to Ruby's `RangeError`. + /// Maps a native timestamp range error to Ruby's `RangeError`. fn expiration_range_error(ruby: &Ruby, error: ComponentRange) -> Error { range_error( ruby, @@ -447,6 +466,11 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { // SameSite enum let same_site_class = gem_module.define_class("SameSite", ruby.class_object())?; SameSite::define_constants(same_site_class)?; + same_site_class.define_method("to_s", method!(SameSite::to_s, 0))?; + same_site_class.define_method("to_sym", method!(SameSite::to_sym, 0))?; + same_site_class.define_method("==", method!(SameSite::equals, 1))?; + same_site_class.define_method("eql?", method!(SameSite::is_eql, 1))?; + same_site_class.define_method("hash", method!(SameSite::hash_value, 0))?; // Cookie class let cookie_class = gem_module.define_class("Cookie", ruby.class_object())?; @@ -459,11 +483,13 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { cookie_class.define_method("secure?", method!(Cookie::secure, 0))?; cookie_class.define_method("same_site_lax?", method!(Cookie::same_site_lax, 0))?; cookie_class.define_method("same_site_strict?", method!(Cookie::same_site_strict, 0))?; + cookie_class.define_method("same_site", method!(Cookie::same_site, 0))?; cookie_class.define_method("path", method!(Cookie::path, 0))?; cookie_class.define_method("domain", method!(Cookie::domain, 0))?; cookie_class.define_method("max_age", method!(Cookie::max_age, 0))?; cookie_class.define_method("expires_at", method!(Cookie::expires_at, 0))?; cookie_class.define_method("expires", method!(Cookie::expires, 0))?; + cookie_class.define_method("to_s", method!(Cookie::to_s, 0))?; // Jar class let jar_class = gem_module.define_class("Jar", ruby.class_object())?; diff --git a/src/emulate.rs b/src/emulate.rs index 6c1713b..c1c20ac 100644 --- a/src/emulate.rs +++ b/src/emulate.rs @@ -1,8 +1,5 @@ use ::serde::Deserialize; -use magnus::{ - Error, Module, Object, RModule, Ruby, Value, function, method, - typed_data::{Inspect, Obj}, -}; +use magnus::{Error, Module, Object, RModule, Ruby, Value, function, method, typed_data::Obj}; use crate::options::{NativeOption, Options}; @@ -41,9 +38,10 @@ impl Builder { } } +// Defines constant registration, `into_ffi`/`from_ffi`, and handlers for +// Ruby's `to_s`, `==`, `eql?`, and `hash` methods. define_ruby_enum!( /// An emulation profile. - const, Profile, "Wreq::Profile", wreq_util::Profile, @@ -187,17 +185,19 @@ define_ruby_enum!( Opera131, ); +// Defines constant registration, `into_ffi`/`from_ffi`, and handlers for +// Ruby's `to_s`, `to_sym`, `==`, `eql?`, and `hash` methods. define_ruby_enum!( /// An emulation profile for OS. - const, Platform, "Wreq::Platform", wreq_util::Platform, - Windows, - MacOS, - Linux, - Android, - IOS, + symbols: + Windows => "windows", + MacOS => "macos", + Linux => "linux", + Android => "android", + IOS => "ios", ); /// A struct to represent the `EmulationOption` class. @@ -205,22 +205,6 @@ define_ruby_enum!( #[magnus::wrap(class = "Wreq::Emulation", free_immediately, size)] pub struct Emulation(pub wreq_util::Emulation); -// ===== impl Profile ===== - -impl Profile { - pub fn to_s(&self) -> String { - self.into_ffi().inspect() - } -} - -// ===== impl Platform ===== - -impl Platform { - pub fn to_s(&self) -> String { - self.into_ffi().inspect() - } -} - // ===== impl Emulation ===== impl Emulation { @@ -266,11 +250,18 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { // Profile enum binding let profile = gem_module.define_class("Profile", ruby.class_object())?; profile.define_method("to_s", method!(Profile::to_s, 0))?; + profile.define_method("==", method!(Profile::equals, 1))?; + profile.define_method("eql?", method!(Profile::is_eql, 1))?; + profile.define_method("hash", method!(Profile::hash_value, 0))?; Profile::define_constants(profile)?; // Platform enum binding let platform = gem_module.define_class("Platform", ruby.class_object())?; platform.define_method("to_s", method!(Platform::to_s, 0))?; + platform.define_method("to_sym", method!(Platform::to_sym, 0))?; + platform.define_method("==", method!(Platform::equals, 1))?; + platform.define_method("eql?", method!(Platform::is_eql, 1))?; + platform.define_method("hash", method!(Platform::hash_value, 0))?; Platform::define_constants(platform)?; // Emulation class binding diff --git a/src/error.rs b/src/error.rs index 81a8ed7..5de9fbd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -101,7 +101,7 @@ pub fn fork_error(ruby: &Ruby, owner_pid: u32, current_pid: u32) -> MagnusError MagnusError::new( ruby.get_inner(&FORK_ERROR), format!( - "wreq loaded in process {owner_pid} cannot be used after fork in process {current_pid}" + "wreq-ruby was loaded in process {owner_pid} and cannot be used after fork in process {current_pid}" ), ) } @@ -213,7 +213,7 @@ pub fn argument_error(ruby: &Ruby, message: impl Into) -> MagnusError { MagnusError::new(ruby.exception_arg_error(), message.into()) } -/// Build a `RangeError` from a validation message. +/// Builds a Ruby `RangeError` from a validation message. pub fn range_error(ruby: &Ruby, message: impl Into) -> MagnusError { MagnusError::new(ruby.exception_range_error(), message.into()) } diff --git a/src/http.rs b/src/http.rs index 9b00008..d5f8376 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,31 +1,38 @@ -use magnus::{Error, Module, RModule, Ruby, TryConvert, Value, method, typed_data::Inspect}; +use magnus::{Error, Module, RModule, Ruby, TryConvert, Value, method}; +// Defines constant registration, `into_ffi`/`from_ffi`, and handlers for +// Ruby's `to_s`, `==`, `eql?`, and `hash` methods. +// `wreq::Version` only exposes protocol strings through `Debug`, so static +// labels avoid allocating an intermediate `String`. define_ruby_enum!( /// An HTTP version. - const, Version, "Wreq::Version", wreq::Version, - HTTP_09, - HTTP_10, - HTTP_11, - HTTP_2, - HTTP_3, + strings: + HTTP_09 => "HTTP/0.9", + HTTP_10 => "HTTP/1.0", + HTTP_11 => "HTTP/1.1", + HTTP_2 => "HTTP/2.0", + HTTP_3 => "HTTP/3.0", ); +// Defines constant registration, `into_ffi`/`from_ffi`, and handlers for +// Ruby's `to_s`, `to_sym`, `==`, `eql?`, and `hash` methods. define_ruby_enum!( /// An HTTP method. Method, "Wreq::Method", wreq::Method, - GET, - HEAD, - POST, - PUT, - DELETE, - OPTIONS, - TRACE, - PATCH, + symbols: + GET => "get", + HEAD => "head", + POST => "post", + PUT => "put", + DELETE => "delete", + OPTIONS => "options", + TRACE => "trace", + PATCH => "patch", ); /// HTTP status code. @@ -33,24 +40,6 @@ define_ruby_enum!( #[magnus::wrap(class = "Wreq::StatusCode", free_immediately, size)] pub struct StatusCode(pub wreq::StatusCode); -// ===== impl Version ===== - -impl Version { - /// Convert version to string. - #[inline] - pub fn to_s(&self) -> String { - self.into_ffi().inspect() - } - - /// Value-based equality for Ruby (`==`). - #[inline] - pub fn equals(&self, other: Value) -> bool { - <&Version>::try_convert(other) - .map(|other| *self == *other) - .unwrap_or(false) - } -} - impl TryConvert for Version { fn try_convert(value: magnus::Value) -> Result { <&Version>::try_convert(value).cloned() @@ -101,6 +90,35 @@ impl StatusCode { pub fn to_s(&self) -> String { self.0.to_string() } + + /// Value-based equality for Ruby (`==`). + #[inline] + pub fn equals(&self, other: Value) -> bool { + <&StatusCode>::try_convert(other) + .map(|other| *self == *other) + .unwrap_or(false) + } + + /// Strict equality for Ruby (`eql?`). + #[inline] + pub fn is_eql(&self, other: Value) -> bool { + self.equals(other) + } + + /// Hash value for Ruby (`hash`). + #[inline] + pub fn hash_value(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.0.hash(&mut hasher); + hasher.finish() + } + + /// Return the status code as an integer (Ruby `to_i`). + #[inline] + pub const fn to_i(&self) -> u16 { + self.0.as_u16() + } } impl From for StatusCode { @@ -112,11 +130,18 @@ impl From for StatusCode { pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { let method_class = gem_module.define_class("Method", ruby.class_object())?; Method::define_constants(method_class)?; + method_class.define_method("to_s", method!(Method::to_s, 0))?; + method_class.define_method("to_sym", method!(Method::to_sym, 0))?; + method_class.define_method("==", method!(Method::equals, 1))?; + method_class.define_method("eql?", method!(Method::is_eql, 1))?; + method_class.define_method("hash", method!(Method::hash_value, 0))?; let version_class = gem_module.define_class("Version", ruby.class_object())?; Version::define_constants(version_class)?; version_class.define_method("to_s", method!(Version::to_s, 0))?; version_class.define_method("==", method!(Version::equals, 1))?; + version_class.define_method("eql?", method!(Version::is_eql, 1))?; + version_class.define_method("hash", method!(Version::hash_value, 0))?; let status_code_class = gem_module.define_class("StatusCode", ruby.class_object())?; status_code_class.define_method("as_int", method!(StatusCode::as_int, 0))?; @@ -126,6 +151,10 @@ pub fn include(ruby: &Ruby, gem_module: &RModule) -> Result<(), Error> { status_code_class.define_method("client_error?", method!(StatusCode::is_client_error, 0))?; status_code_class.define_method("server_error?", method!(StatusCode::is_server_error, 0))?; status_code_class.define_method("to_s", method!(StatusCode::to_s, 0))?; + status_code_class.define_method("==", method!(StatusCode::equals, 1))?; + status_code_class.define_method("eql?", method!(StatusCode::is_eql, 1))?; + status_code_class.define_method("hash", method!(StatusCode::hash_value, 0))?; + status_code_class.define_method("to_i", method!(StatusCode::to_i, 0))?; Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index 82d852d..ffe35ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ mod http; mod options; mod rt; mod serde; +mod time; use magnus::{Error, Module, Ruby, Value}; diff --git a/src/macros.rs b/src/macros.rs index f9724e9..412318b 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -54,88 +54,105 @@ macro_rules! extract_native_option { } macro_rules! define_ruby_enum { - ($(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, $($variant:ident),* $(,)?) => { - define_ruby_enum!($(#[$meta])* $enum_type, $ruby_class, $ffi_type, $( ($variant, $variant) ),*); + ($(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, strings: $($variant:ident => $display:expr),* $(,)?) => { + define_ruby_enum!(@impl $(#[$meta])* $enum_type, $ruby_class, $ffi_type, [$(($variant, $display)),*], []); }; - ($(#[$meta:meta])* const, $enum_type:ident, $ruby_class:expr, $ffi_type:ty, $($variant:ident),* $(,)?) => { - define_ruby_enum!($(#[$meta])* const, $enum_type, $ruby_class, $ffi_type, $( ($variant, $variant) ),*); + ($(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, symbols: $($variant:ident => $symbol:expr),* $(,)?) => { + define_ruby_enum!(@impl $(#[$meta])* $enum_type, $ruby_class, $ffi_type, [$(($variant, stringify!($variant))),*], [$($variant => $symbol),*]); }; - ($(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, $(($rust_variant:ident, $ffi_variant:ident)),* $(,)?) => { + ($(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, $($variant:ident),* $(,)?) => { + define_ruby_enum!(@impl $(#[$meta])* $enum_type, $ruby_class, $ffi_type, [$(($variant, stringify!($variant))),*], []); + }; + + (@impl $(#[$meta:meta])* $enum_type:ident, $ruby_class:expr, $ffi_type:ty, [$(($variant:ident, $display:expr)),*], [$($symbol_variant:ident => $symbol:expr),*]) => { $(#[$meta])* #[magnus::wrap(class = $ruby_class, free_immediately, size)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[allow(non_camel_case_types)] #[allow(clippy::upper_case_acronyms)] pub enum $enum_type { - $($rust_variant),* + $($variant),* } impl $enum_type { + /// Return the static text exposed through Ruby's `to_s`. + #[inline] + pub const fn to_s(&self) -> &'static str { + match self { + $(<$enum_type>::$variant => $display,)* + } + } + + define_ruby_enum!(@to_sym $enum_type, [$($symbol_variant => $symbol),*]); + + /// Convert this Ruby wrapper into its native enum value. pub fn into_ffi(self) -> $ffi_type { match self { - $(<$enum_type>::$rust_variant => <$ffi_type>::$ffi_variant,)* + $(<$enum_type>::$variant => <$ffi_type>::$variant,)* } } + /// Convert a known native enum value into its Ruby wrapper. #[allow(dead_code)] pub fn from_ffi(ffi: $ffi_type) -> Self { #[allow(unreachable_patterns)] match ffi { - $(<$ffi_type>::$ffi_variant => <$enum_type>::$rust_variant,)* + $(<$ffi_type>::$variant => <$enum_type>::$variant,)* _ => unreachable!(), } } + /// Register every enum variant as a constant on the Ruby class. pub fn define_constants(class: magnus::RClass) -> Result<(), magnus::Error> { - $(class.const_set(stringify!($rust_variant), <$enum_type>::$rust_variant)?;)* + $(class.const_set(stringify!($variant), <$enum_type>::$variant)?;)* Ok(()) } - } - }; - ($(#[$meta:meta])* const, $enum_type:ident, $ruby_class:expr, $ffi_type:ty, $(($rust_variant:ident, $ffi_variant:ident)),* $(,)?) => { - $(#[$meta])* - #[magnus::wrap(class = $ruby_class, free_immediately, size)] - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] - #[allow(non_camel_case_types)] - #[allow(clippy::upper_case_acronyms)] - pub enum $enum_type { - $($rust_variant),* - } - - impl $enum_type { - pub const fn into_ffi(self) -> $ffi_type { - match self { - $(<$enum_type>::$rust_variant => <$ffi_type>::$ffi_variant,)* - } + /// Compare two wrapped enum values for Ruby's `==`. + pub fn equals(&self, other: magnus::Value) -> bool { + use magnus::TryConvert; + <&$enum_type>::try_convert(other) + .map(|other| *self == *other) + .unwrap_or(false) } - #[allow(dead_code)] - pub const fn from_ffi(ffi: $ffi_type) -> Self { - #[allow(unreachable_patterns)] - match ffi { - $(<$ffi_type>::$ffi_variant => <$enum_type>::$rust_variant,)* - _ => unreachable!(), - } + /// Compare two wrapped enum values for Ruby's `eql?`. + pub fn is_eql(&self, other: magnus::Value) -> bool { + self.equals(other) } - pub fn define_constants(class: magnus::RClass) -> Result<(), magnus::Error> { - $(class.const_set(stringify!($rust_variant), <$enum_type>::$rust_variant)?;)* - Ok(()) + /// Return a hash consistent with Ruby's `eql?` contract. + pub fn hash_value(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.hash(&mut hasher); + hasher.finish() } } }; + + (@to_sym $enum_type:ident, []) => {}; + + (@to_sym $enum_type:ident, [$($variant:ident => $symbol:expr),+]) => { + /// Return the configured Ruby symbol for this enum value. + #[inline] + pub fn to_sym(ruby: &magnus::Ruby, rb_self: &Self) -> magnus::Symbol { + let name = match *rb_self { + $(<$enum_type>::$variant => $symbol,)+ + }; + ruby.to_symbol(name) + } + }; } macro_rules! extract_request { - ($args:expr, $required:ty) => {{ + ($ruby:expr, $args:expr, $required:ty) => {{ + crate::rt::ensure_current($ruby)?; let args = magnus::scan_args::scan_args::<$required, (), (), (), magnus::RHash, ()>($args)?; let required = args.required; - let ruby = magnus::Ruby::get_with(args.keywords); - crate::rt::ensure_current(&ruby)?; - let request = crate::client::req::Request::new(&ruby, args.keywords)?; + let request = crate::client::req::Request::new($ruby, args.keywords)?; (required, request) }}; } diff --git a/src/time.rs b/src/time.rs new file mode 100644 index 0000000..a2ae8c6 --- /dev/null +++ b/src/time.rs @@ -0,0 +1,56 @@ +//! Ruby time-value conversions shared by native binding modules. + +use std::time::Duration as StdDuration; + +use magnus::numeric::NumericValue; +use magnus::{Error, Integer, Ruby, TryConvert, Value, value::ReprValue}; + +use crate::error::argument_error; + +/// A crate-wide duration converted from non-negative Ruby `Numeric` seconds. +/// +/// Ruby integers are converted directly so the full `u64` seconds range is +/// retained. Other numeric values are converted through `f64` and then rounded +/// to the nanosecond precision of [`StdDuration`]. +pub(crate) struct Duration(pub(crate) StdDuration); + +impl TryConvert for Duration { + /// Convert integer or fractional Ruby seconds without string coercion. + /// + /// # Errors + /// + /// Returns `TypeError` for non-numeric values and `ArgumentError` for + /// negative, non-finite, or out-of-range durations. + fn try_convert(value: Value) -> Result { + let ruby = Ruby::get_with(value); + let numeric = NumericValue::try_convert(value)?; + + // `u64::try_convert` starts with `Integer::try_convert`. Probe with + // `from_value` so a fractional Numeric takes the fallback without + // using `TypeError` as control flow or repeating the Integer check. + if let Some(integer) = Integer::from_value(numeric.as_value()) { + return integer + .to_u64() + .map(StdDuration::from_secs) + .map(Self) + .map_err(|_| invalid_duration(&ruby)); + } + + // `Float::try_convert(...).to_f64()` takes a Ruby Float detour. The + // `rb_num2dbl`-backed conversion yields the primitive required by + // `StdDuration` directly from the already-checked Numeric. + f64::try_convert(numeric.as_value()) + .and_then(|seconds| { + StdDuration::try_from_secs_f64(seconds).map_err(|_| invalid_duration(&ruby)) + }) + .map(Self) + } +} + +/// Build the shared Ruby error for a numeric value outside `Duration`'s domain. +fn invalid_duration(ruby: &Ruby) -> Error { + argument_error( + ruby, + "duration must be finite, non-negative, and within the supported range", + ) +} diff --git a/test/cookie_test.rb b/test/cookie_test.rb index 82b80e3..6915437 100644 --- a/test/cookie_test.rb +++ b/test/cookie_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "test_helper" +require "bigdecimal" require "open3" require "rbconfig" @@ -43,6 +44,14 @@ def test_add_and_get_all assert_equal true, (c.secure || c.secure?) end + def test_add_rejects_invalid_cookie_type + error = assert_raises(TypeError) do + @jar.add(Object.new, @base_url) + end + + assert_equal "cookie must be a Wreq::Cookie or String", error.message + end + def test_add_multiple_and_remove @jar.add("a=1; Path=/", @base_url) @jar.add("b=2; Path=/", @base_url) @@ -86,7 +95,7 @@ def test_max_age_and_expires_optional @jar.add("exp=1; Expires=#{t.gmtime.strftime("%a, %d %b %Y %H:%M:%S GMT")}; Path=/", @base_url) c2 = @jar.get_all.find { |c| c.name == "exp" } assert c2 - # expires_at returns Time and expires retains the numeric compatibility API + # expires_at returns Time. expires remains available for numeric callers. assert_kind_of Time, c2.expires_at assert_predicate c2.expires_at, :utc? if (e = c2.expires) @@ -114,6 +123,8 @@ def test_cookie_new_minimal assert_equal false, (c.secure || c.secure?) assert_equal false, c.same_site_lax? assert_equal false, c.same_site_strict? + assert_nil c.same_site + assert_equal "sid=abc", c.to_s end def test_cookie_new_full_attributes @@ -143,6 +154,15 @@ def test_cookie_new_full_attributes assert_equal true, (c.secure || c.secure?) assert_equal true, c.same_site_lax? assert_equal false, c.same_site_strict? + assert_equal Wreq::SameSite::Lax, c.same_site + end + + def test_same_site_reader_distinguishes_none_from_unset + cookie = Wreq::Cookie.new("same-site", "value", same_site: Wreq::SameSite::None) + + assert_equal Wreq::SameSite::None, cookie.same_site + refute cookie.same_site_lax? + refute cookie.same_site_strict? end def test_cookie_new_uses_shared_keyword_validation @@ -173,16 +193,37 @@ def test_expires_accepts_past_time end def test_expires_accepts_integer_and_fractional_timestamps - [1_893_456_000, 1_893_456_000.125, -1.25].each do |timestamp| + [ + 1_893_456_000, + 1_893_456_000.125, + Rational(-5, 4), + BigDecimal("1893456000.125") + ].each do |timestamp| cookie = Wreq::Cookie.new("timestamp", "value", expires: timestamp) assert_kind_of Time, cookie.expires_at assert_predicate cookie.expires_at, :utc? - assert_in_delta timestamp, cookie.expires_at.to_f, 1e-6 - assert_in_delta timestamp, cookie.expires, 1e-6 + assert_in_delta timestamp.to_f, cookie.expires_at.to_f, 1e-6 + assert_in_delta timestamp.to_f, cookie.expires, 1e-6 end end + def test_expires_normalizes_time_to_utc_without_losing_precision + expiration = Time.new( + 2030, + 1, + 1, + 12, + 34, + Rational(123_456_789, 1_000_000_000), + "+09:00" + ) + actual = Wreq::Cookie.new("offset", "value", expires: expiration).expires_at + + assert_predicate actual, :utc? + assert_equal expiration.to_r, actual.to_r + end + def test_expires_rejects_non_finite_timestamps [Float::NAN, Float::INFINITY, -Float::INFINITY].each do |timestamp| error = assert_raises(ArgumentError) do @@ -306,10 +347,12 @@ def test_same_site_flags_from_parsed_header @jar.add("s2=1; Path=/; SameSite=Lax", @base_url) cookies = @jar.get_all - h = cookies.to_h { |ck| [ck.name, [ck.same_site_strict?, ck.same_site_lax?]] } + h = cookies.to_h do |cookie| + [cookie.name, [cookie.same_site, cookie.same_site_strict?, cookie.same_site_lax?]] + end - assert_equal [true, false], h["s1"] - assert_equal [false, true], h["s2"] + assert_equal [Wreq::SameSite::Strict, true, false], h["s1"] + assert_equal [Wreq::SameSite::Lax, false, true], h["s2"] end def test_request_uncompressed_cookies diff --git a/test/fork_test.rb b/test/fork_test.rb index 60da69a..7fc7b1d 100644 --- a/test/fork_test.rb +++ b/test/fork_test.rb @@ -1,11 +1,27 @@ # frozen_string_literal: true require "test_helper" -require "open3" require "rbconfig" +require "tempfile" require "timeout" class ForkTest < Minitest::Test + FORK_ERROR_LABELS = %w[ + before_runtime + invalid_client + invalid_request + fresh_body_sender + inherited_body_sender_push + inherited_body_sender_close + inherited_body_sender_closed + fresh_client + inherited_client + inherited_response + inherited_response_text + inherited_response_chunks + inherited_response_close + ].freeze + def test_fork_error_is_a_runtime_error assert_operator Wreq::ForkError, :<, RuntimeError end @@ -13,21 +29,54 @@ def test_fork_error_is_a_runtime_error def test_loaded_extension_is_rejected_after_fork skip "fork is not supported on this platform" unless Process.respond_to?(:fork) - lib_dir = File.expand_path("../lib", __dir__) - script = File.expand_path("scripts/fork_safety.rb", __dir__) - - stdout, stderr, status = Timeout.timeout(20) do - Open3.capture3(RbConfig.ruby, "-I", lib_dir, script) - end + stdout, stderr, status = run_fork_script("fork_safety.rb") assert status.success?, "subprocess failed with #{status.inspect}: #{stderr}" assert_equal "ok\n", stdout - %w[before_runtime fresh_client inherited_client].each do |label| + FORK_ERROR_LABELS.each do |label| assert_match(/#{label}=Wreq::ForkError:.*cannot be used after fork/, stderr) assert_match(/#{label}_retry=Wreq::ForkError:.*cannot be used after fork/, stderr) end + assert_match(/inherited_gc=ok/, stderr) refute_match(/\[BUG\]|segmentation fault|panicked/i, stderr) - rescue Timeout::Error - flunk "fork safety subprocess timed out" + end + + private + + def run_fork_script(name) + lib_dir = File.expand_path("../lib", __dir__) + script = File.expand_path("scripts/#{name}", __dir__) + + Tempfile.create("wreq-fork-stdout") do |stdout| + Tempfile.create("wreq-fork-stderr") do |stderr| + pid = Process.spawn( + RbConfig.ruby, + "-I", + lib_dir, + script, + out: stdout, + err: stderr, + pgroup: true + ) + status = Timeout.timeout(30) { Process.wait2(pid).last } + stdout.rewind + stderr.rewind + return [stdout.read, stderr.read, status] + rescue Timeout::Error + begin + Process.kill("KILL", -pid) + rescue Errno::ESRCH + nil + end + + begin + Process.wait(pid) + rescue Errno::ECHILD + nil + end + + flunk "#{name} timed out" + end + end end end diff --git a/test/scripts/fork_safety.rb b/test/scripts/fork_safety.rb index b50b4a5..7593b40 100644 --- a/test/scripts/fork_safety.rb +++ b/test/scripts/fork_safety.rb @@ -2,6 +2,7 @@ require "socket" require "timeout" +require "weakref" require "wreq" $stdout.sync = true @@ -34,11 +35,14 @@ def expect_fork_error(label) end expect_fork_error("before_runtime") { Wreq::Client.new } +expect_fork_error("invalid_client") { Wreq::Client.new(unknown: true) } +expect_fork_error("invalid_request") { Wreq.get(1) } +expect_fork_error("fresh_body_sender") { Wreq::BodySender.new(0) } server = TCPServer.new("127.0.0.1", 0) port = server.addr[1] server_pid = fork do - 2.times do + 3.times do ready = IO.select([server], nil, nil, 10) exit! 4 unless ready @@ -62,8 +66,42 @@ def expect_fork_error(label) client = Wreq::Client.new abort "parent warm-up failed" unless client.get(url).bytes == "ok" +def build_inherited_objects(client, url) + [Wreq::Client.new, Wreq::BodySender.new, client.get(url)] +end + +inherited_objects = build_inherited_objects(client, url) +inherited_weak_refs = inherited_objects.map { |object| WeakRef.new(object) } + +expect_fork_error("inherited_body_sender_push") do + inherited_objects[1].push("chunk") +end +expect_fork_error("inherited_body_sender_close") { inherited_objects[1].close } +expect_fork_error("inherited_body_sender_closed") { inherited_objects[1].closed? } expect_fork_error("fresh_client") { Wreq::Client.new } expect_fork_error("inherited_client") { client.get(url) } +expect_fork_error("inherited_response") { inherited_objects[2].bytes } +expect_fork_error("inherited_response_text") { inherited_objects[2].text(1) } +expect_fork_error("inherited_response_chunks") { inherited_objects[2].chunks } +expect_fork_error("inherited_response_close") { inherited_objects[2].close } + +# Release the earlier test blocks so this array is the only strong reference. +GC.start(full_mark: true, immediate_sweep: true) +gc_pid = fork do + inherited_objects = nil + 3.times { GC.start(full_mark: true, immediate_sweep: true) } + alive = inherited_weak_refs.each_index.select do |index| + inherited_weak_refs[index].weakref_alive? + end + abort "inherited objects were not collected: #{alive.join(", ")}" unless alive.empty? + warn "inherited_gc=ok" + exit! 0 +rescue => error + warn "inherited_gc=unexpected #{error.class}: #{error.message}" + exit! 5 +end +_, gc_status = Process.wait2(gc_pid) +abort "inherited GC child failed with #{gc_status.inspect}" unless gc_status.success? abort "parent request after fork failed" unless client.get(url).bytes == "ok" _, server_status = Process.wait2(server_pid) diff --git a/test/timeout_test.rb b/test/timeout_test.rb new file mode 100644 index 0000000..eb734bc --- /dev/null +++ b/test/timeout_test.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require "test_helper" +require "socket" + +class TimeoutTest < Minitest::Test + SUBSECOND_TIMEOUT = 0.25 + SERVER_DELAY = 1.2 + + def test_client_duration_options_accept_numeric_seconds + [1, 0.125, Rational(1, 8), 0, nil].each do |value| + options = client_duration_options.to_h { |name| [name, value] } + + assert_instance_of Wreq::Client, Wreq::Client.new(**options) + end + end + + def test_each_client_duration_option_rejects_negative_seconds + client_duration_options.each do |name| + error = assert_raises(ArgumentError) do + Wreq::Client.new(**{name => -0.25}) + end + + assert_includes error.message, ":#{name}" + end + end + + def test_duration_rejects_invalid_numeric_seconds + invalid_values = [ + -1, + Float::NAN, + Float::INFINITY, + -Float::INFINITY, + Float::MAX, + 2**256 + ] + + invalid_values.each do |value| + error = assert_raises(ArgumentError) do + Wreq::Client.new(timeout: value) + end + + assert_includes error.message, ":timeout" + end + end + + def test_duration_rejects_non_numeric_values + error = assert_raises(TypeError) do + Wreq::Client.new(timeout: "0.25") + end + + assert_includes error.message, ":timeout" + end + + def test_request_duration_options_reject_invalid_values_before_network_io + invalid_values = [ + -1, + -0.25, + Float::NAN, + Float::INFINITY, + -Float::INFINITY, + Float::MAX, + 2**256, + "0.25" + ] + + %i[timeout read_timeout].each do |name| + invalid_values.each do |value| + error_class = value.is_a?(String) ? TypeError : ArgumentError + error = assert_raises(error_class) do + Wreq.get("not a url", **{name => value}) + end + + assert_includes error.message, ":#{name}" + end + end + end + + def test_request_timeouts_accept_integer_and_nil_values + with_http_server do |url| + response = Wreq.get(url, timeout: 1, read_timeout: 1) + + assert_equal 200, response.code + assert_equal "ok", response.text + end + + with_http_server do |url| + response = Wreq.get(url, timeout: nil, read_timeout: nil) + + assert_equal 200, response.code + assert_equal "ok", response.text + end + end + + def test_client_fractional_timeout_preserves_subsecond_value + client = Wreq::Client.new(timeout: SUBSECOND_TIMEOUT) + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_fractional_timeout { client.get(url) } + end + end + + def test_request_timeout_override_preserves_subsecond_value + client = Wreq::Client.new(timeout: 2) + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_fractional_timeout do + client.get(url, timeout: SUBSECOND_TIMEOUT) + end + end + end + + def test_request_read_timeout_override_preserves_subsecond_value + client = Wreq::Client.new(read_timeout: 2) + + with_http_server(body_delay: SERVER_DELAY) do |url| + assert_fractional_timeout do + client.get(url, read_timeout: SUBSECOND_TIMEOUT).text + end + end + end + + def test_zero_request_timeouts_expire_immediately + client = Wreq::Client.new + + with_http_server(response_delay: SERVER_DELAY) do |url| + assert_immediate_timeout { client.get(url, timeout: 0) } + end + + with_http_server(body_delay: SERVER_DELAY) do |url| + assert_immediate_timeout do + client.get(url, read_timeout: 0).text + end + end + end + + private + + def client_duration_options + options = %i[ + timeout + connect_timeout + read_timeout + tcp_keepalive + tcp_keepalive_interval + pool_idle_timeout + ] + if RUBY_PLATFORM.match?(/linux|android|fuchsia/) + options << :tcp_user_timeout + end + options + end + + def assert_fractional_timeout + elapsed = measure_elapsed do + assert_raises(Wreq::TimeoutError) { yield } + end + + assert_operator elapsed, :>=, 0.1, + "Fractional timeout fired too early after #{elapsed.round(3)} seconds" + assert_operator elapsed, :<, 0.8, + "Fractional timeout fired too late after #{elapsed.round(3)} seconds" + end + + def assert_immediate_timeout + elapsed = measure_elapsed do + assert_raises(Wreq::TimeoutError) { yield } + end + + assert_operator elapsed, :<, 0.5, + "Zero timeout did not expire immediately (#{elapsed.round(3)} seconds)" + end + + def measure_elapsed + started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + yield + Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at + end + + def with_http_server(response_delay: 0, body_delay: 0) + server = TCPServer.new("127.0.0.1", 0) + thread = Thread.new do + socket = server.accept + read_request(socket) + sleep response_delay if response_delay.positive? + + socket.write( + "HTTP/1.1 200 OK\r\n" \ + "Content-Length: 2\r\n" \ + "Connection: close\r\n\r\n" + ) + sleep body_delay if body_delay.positive? + socket.write("ok") + rescue IOError, SystemCallError + nil + ensure + socket&.close + server.close unless server.closed? + end + thread.report_on_exception = false + + yield "http://127.0.0.1:#{server.addr[1]}/" + ensure + server&.close unless server&.closed? + thread&.kill + thread&.join(1) + end + + def read_request(socket) + while (line = socket.gets) + break if line == "\r\n" + end + end +end diff --git a/test/value_semantics_test.rb b/test/value_semantics_test.rb new file mode 100644 index 0000000..78edc27 --- /dev/null +++ b/test/value_semantics_test.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require "test_helper" + +class ValueSemanticsTest < Minitest::Test + # ---- StatusCode ---- + + def setup + @response = Wreq.get("#{HTTPBIN_URL}/status/201") + end + + def test_status_code_to_i + assert_equal 201, @response.status.to_i + end + + def test_status_code_as_int_still_works + assert_equal 201, @response.status.as_int + end + + def test_status_code_to_i_matches_as_int + assert_equal @response.status.as_int, @response.status.to_i + end + + def test_status_code_equality + a = @response.status + b = @response.status + assert_equal a, b + end + + def test_status_code_eql + a = @response.status + b = @response.status + assert a.eql?(b) + end + + def test_status_code_hash_consistent + a = @response.status + b = @response.status + assert_equal a.hash, b.hash + end + + def test_status_code_as_hash_key + status = @response.status + h = {status => "created"} + assert_equal "created", h[@response.status] + end + + def test_status_code_in_set + s = Set.new + s.add(@response.status) + assert_includes s, @response.status + end + + def test_status_code_not_equal_to_integer + refute_equal @response.status, 201 + refute @response.status.eql?(201) + end + + def test_status_code_different_values_not_equal + ok = Wreq.get("#{HTTPBIN_URL}/status/200").status + created = @response.status + refute_equal ok, created + refute ok.eql?(created) + end + + def test_response_code_still_returns_integer + assert_instance_of Integer, @response.code + assert_equal 201, @response.code + end + + # ---- Version ---- + + def test_version_equality + assert_equal Wreq::Version::HTTP_11, Wreq::Version::HTTP_11 + assert_equal "HTTP/0.9", Wreq::Version::HTTP_09.to_s + assert_equal "HTTP/1.0", Wreq::Version::HTTP_10.to_s + assert_equal "HTTP/1.1", Wreq::Version::HTTP_11.to_s + assert_equal "HTTP/2.0", Wreq::Version::HTTP_2.to_s + assert_equal "HTTP/3.0", Wreq::Version::HTTP_3.to_s + end + + def test_version_eql + v = Wreq::Version::HTTP_11 + assert v.eql?(Wreq::Version::HTTP_11) + end + + def test_version_hash_consistent + a = Wreq::Version::HTTP_11 + b = Wreq::Version::HTTP_11 + assert_equal a.hash, b.hash + end + + def test_version_different_values_not_equal + refute_equal Wreq::Version::HTTP_11, Wreq::Version::HTTP_2 + end + + def test_version_as_hash_key + v = Wreq::Version::HTTP_11 + h = {v => "http1.1"} + assert_equal "http1.1", h[Wreq::Version::HTTP_11] + end + + def test_version_in_set + s = Set.new([Wreq::Version::HTTP_11, Wreq::Version::HTTP_2]) + assert_includes s, Wreq::Version::HTTP_11 + assert_includes s, Wreq::Version::HTTP_2 + refute_includes s, Wreq::Version::HTTP_3 + end + + # ---- Method ---- + + def test_method_to_s + assert_equal "GET", Wreq::Method::GET.to_s + assert_equal "POST", Wreq::Method::POST.to_s + assert_equal "PUT", Wreq::Method::PUT.to_s + assert_equal "DELETE", Wreq::Method::DELETE.to_s + assert_equal "HEAD", Wreq::Method::HEAD.to_s + assert_equal "OPTIONS", Wreq::Method::OPTIONS.to_s + assert_equal "TRACE", Wreq::Method::TRACE.to_s + assert_equal "PATCH", Wreq::Method::PATCH.to_s + end + + def test_method_to_sym + assert_equal :get, Wreq::Method::GET.to_sym + assert_equal :post, Wreq::Method::POST.to_sym + assert_equal :put, Wreq::Method::PUT.to_sym + assert_equal :delete, Wreq::Method::DELETE.to_sym + assert_equal :head, Wreq::Method::HEAD.to_sym + assert_equal :options, Wreq::Method::OPTIONS.to_sym + assert_equal :trace, Wreq::Method::TRACE.to_sym + assert_equal :patch, Wreq::Method::PATCH.to_sym + end + + def test_method_equality + assert_equal Wreq::Method::GET, Wreq::Method::GET + refute_equal Wreq::Method::GET, Wreq::Method::POST + end + + def test_method_eql + assert Wreq::Method::GET.eql?(Wreq::Method::GET) + refute Wreq::Method::GET.eql?(Wreq::Method::POST) + end + + def test_method_hash_consistent + assert_equal Wreq::Method::GET.hash, Wreq::Method::GET.hash + refute_equal Wreq::Method::GET.hash, Wreq::Method::POST.hash + end + + def test_method_as_hash_key + h = {Wreq::Method::GET => "get it"} + assert_equal "get it", h[Wreq::Method::GET] + assert_nil h[Wreq::Method::POST] + end + + # ---- SameSite ---- + + def test_same_site_to_s + assert_equal "Strict", Wreq::SameSite::Strict.to_s + assert_equal "Lax", Wreq::SameSite::Lax.to_s + assert_equal "None", Wreq::SameSite::None.to_s + end + + def test_same_site_to_sym + assert_equal :strict, Wreq::SameSite::Strict.to_sym + assert_equal :lax, Wreq::SameSite::Lax.to_sym + assert_equal :none, Wreq::SameSite::None.to_sym + end + + def test_same_site_equality + assert_equal Wreq::SameSite::Lax, Wreq::SameSite::Lax + refute_equal Wreq::SameSite::Lax, Wreq::SameSite::Strict + end + + def test_same_site_eql_and_hash + assert Wreq::SameSite::Lax.eql?(Wreq::SameSite::Lax) + assert_equal Wreq::SameSite::Lax.hash, Wreq::SameSite::Lax.hash + end + + # ---- Profile ---- + + def test_profile_equality + assert_equal Wreq::Profile::Chrome134, Wreq::Profile::Chrome134 + refute_equal Wreq::Profile::Chrome134, Wreq::Profile::Chrome135 + assert_equal "Chrome134", Wreq::Profile::Chrome134.to_s + assert_equal "SafariIos17_4_1", Wreq::Profile::SafariIos17_4_1.to_s + assert_equal "OkHttp4_12", Wreq::Profile::OkHttp4_12.to_s + end + + def test_profile_eql_and_hash + assert Wreq::Profile::Chrome134.eql?(Wreq::Profile::Chrome134) + assert_equal Wreq::Profile::Chrome134.hash, Wreq::Profile::Chrome134.hash + end + + def test_profile_as_hash_key + h = {Wreq::Profile::Chrome134 => "chrome"} + assert_equal "chrome", h[Wreq::Profile::Chrome134] + assert_nil h[Wreq::Profile::Chrome135] + end + + # ---- Platform ---- + + def test_platform_equality + assert_equal Wreq::Platform::Windows, Wreq::Platform::Windows + refute_equal Wreq::Platform::Windows, Wreq::Platform::Linux + end + + def test_platform_eql_and_hash + assert Wreq::Platform::Windows.eql?(Wreq::Platform::Windows) + assert_equal Wreq::Platform::Windows.hash, Wreq::Platform::Windows.hash + end + + def test_platform_to_sym + assert_equal "Windows", Wreq::Platform::Windows.to_s + assert_equal "MacOS", Wreq::Platform::MacOS.to_s + assert_equal "Linux", Wreq::Platform::Linux.to_s + assert_equal "Android", Wreq::Platform::Android.to_s + assert_equal "IOS", Wreq::Platform::IOS.to_s + + assert_equal :windows, Wreq::Platform::Windows.to_sym + assert_equal :macos, Wreq::Platform::MacOS.to_sym + assert_equal :linux, Wreq::Platform::Linux.to_sym + assert_equal :android, Wreq::Platform::Android.to_sym + assert_equal :ios, Wreq::Platform::IOS.to_sym + end + + # ---- Cross-type comparisons ---- + + def test_cross_type_not_equal + refute_equal Wreq::Method::GET, Wreq::Version::HTTP_11 + refute_equal Wreq::SameSite::Lax, Wreq::Method::GET + refute_equal Wreq::Platform::Windows, Wreq::Profile::Chrome134 + end + + def test_cross_type_eql_false + refute Wreq::Method::GET.eql?(Wreq::Version::HTTP_11) + refute Wreq::SameSite::Lax.eql?(Wreq::Method::GET) + end +end