Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix socksify incompatibility #999

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions lib/dalli/socket.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,14 @@ def self.open(host, port, options = {})
end
end

TCPSOCKET_ORIGINAL_INITIALIZE_PARAMETERS = [[:rest].freeze].freeze
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is memoized so we don't create duplicate arrays every time.

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
Copy link
Contributor Author

@sambostock sambostock Mar 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This just checks for the original parameters ([[:rest]]), but we could also use connect_timeout if we detect any of

  • parameters.include?([:key, :connect_timeout])
  • parameters.include?([:keyreq, :connect_timeout])
  • parameters.any? { |type, _name| type == :keyrest }

I kept it simple, as these would increase the runtime penalty, and I'm not aware of any gems that do monkey patch TCPSocket#initialize correctly, nor am I certain we must use connect_timeout: instead of Timeout in those cases. 🤷

sock = new(host, port, connect_timeout: options[:socket_timeout])
yield(sock)
else
Expand Down
88 changes: 88 additions & 0 deletions test/test_gem_compatibility.rb
Original file line number Diff line number Diff line change
@@ -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
Loading