diff --git a/Gemfile b/Gemfile index 052aa375..494b811f 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,10 @@ group :development, :test do gem 'rubocop-performance' gem 'rubocop-rake' gem 'simplecov' + + # For compatibility testing + gem 'resolv-replace', require: false + gem 'socksify', require: false end group :test do diff --git a/lib/dalli/socket.rb b/lib/dalli/socket.rb index bf25fb3d..1855c471 100644 --- a/lib/dalli/socket.rb +++ b/lib/dalli/socket.rb @@ -97,15 +97,14 @@ def self.open(host, port, options = {}) end end + TCPSOCKET_ORIGINAL_INITIALIZE_PARAMETERS = [[:rest].freeze].freeze + private_constant :TCPSOCKET_ORIGINAL_INITIALIZE_PARAMETERS + def self.create_socket_with_timeout(host, port, options) - # Check that TCPSocket#initialize was not overwritten by resolv-replace gem - # (part of ruby standard library since 3.0.0, should be removed in 3.4.0), - # as it does not handle keyword arguments correctly. - # To check this we are using the fact that resolv-replace - # aliases TCPSocket#initialize method to #original_resolv_initialize. - # https://github.com/ruby/resolv-replace/blob/v0.1.1/lib/resolv-replace.rb#L21 + # Some gems like resolv-replace and socksify monkey patch TCPSocket and make it impossible to use the + # Ruby 3+ `connect_timeout:` keyword argument. If that's the case, we fallback to using the `Timeout` module. if RUBY_VERSION >= '3.0' && - !::TCPSocket.private_instance_methods.include?(:original_resolv_initialize) + ::TCPSocket.instance_method(:initialize).parameters == TCPSOCKET_ORIGINAL_INITIALIZE_PARAMETERS sock = new(host, port, connect_timeout: options[:socket_timeout]) yield(sock) else diff --git a/test/test_gem_compatibility.rb b/test/test_gem_compatibility.rb new file mode 100644 index 00000000..741c18fc --- /dev/null +++ b/test/test_gem_compatibility.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +# JRuby does not support forking, and it doesn't seem worth the effort to make it work. +return unless Process.respond_to?(:fork) + +require_relative 'helper' + +describe 'gem compatibility' do + %w[ + resolv-replace + socksify + ].each do |gem_name| + it "passes smoke test with #{gem_name.inspect} gem required" do + memcached(:binary, rand(21_397..21_896)) do |_, port| + in_isolation(timeout: 10) do + before_client = Dalli::Client.new("127.0.0.1:#{port}") + + assert_round_trip(before_client, "Failed to round-trip key before requiring #{gem_name.inspect}") + + require gem_name + + after_client = Dalli::Client.new("127.0.0.1:#{port}") + + assert_round_trip(after_client, "Failed to round-trip key after requiring #{gem_name.inspect}") + end + end + end + end + + private + + def assert_round_trip(client, message) + expected = SecureRandom.hex(4) + key = "round-trip-#{expected}" + ttl = 10 # seconds + + client.set(key, expected, ttl) + + assert_equal(expected, client.get(key), message) + end + + def in_isolation(timeout:) # rubocop:disable Metrics + r, w = IO.pipe + + pid = fork do + yield + exit!(0) + # We rescue Exception so we can catch everything, including MiniTest::Assertion. + rescue Exception => e # rubocop:disable Lint/RescueException + w.write(Marshal.dump(e)) + ensure + w.close + exit! + end + + begin + Timeout.timeout(timeout) do + _, status = Process.wait2(pid) + w.close + marshaled_exception = r.read + r.close + + unless marshaled_exception.empty? + raise begin + Marshal.load(marshaled_exception) # rubocop:disable Security/MarshalLoad + rescue StandardError => e + raise <<~MESSAGE + Failed to unmarshal error from fork with exit status #{status.exitstatus}! + #{e.class}: #{e} + ---MARSHALED_EXCEPTION--- + #{marshaled_exception} + ------------------------- + MESSAGE + end + end + + unless status.success? + raise "Child process exited with non-zero status #{status.exitstatus} despite piping no exception" + end + + pass + end + rescue Timeout::Error + Process.kill('KILL', pid) + raise "Child process killed after exceeding #{timeout}s timeout" + end + end +end