From 09dea6d4671e5e67562880f32924e6dd70476b0d Mon Sep 17 00:00:00 2001 From: Sam Bostock Date: Thu, 7 Mar 2024 16:25:42 -0500 Subject: [PATCH] Add `resolv-replace` compatibility test While `dalli` doesn't depend on `resolv-replace`, we want to ensure it still works if it is required by the host application. This regression test ensures we don't break compatibility, and adds a framework allowing us to test compatibility with other gems in the future. --- Gemfile | 3 ++ test/test_gem_compatibility.rb | 87 ++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 test/test_gem_compatibility.rb diff --git a/Gemfile b/Gemfile index 052aa375..2f8a4532 100644 --- a/Gemfile +++ b/Gemfile @@ -14,6 +14,9 @@ group :development, :test do gem 'rubocop-performance' gem 'rubocop-rake' gem 'simplecov' + + # For compatibility testing + gem 'resolv-replace', require: false end group :test do diff --git a/test/test_gem_compatibility.rb b/test/test_gem_compatibility.rb new file mode 100644 index 00000000..8f173fd3 --- /dev/null +++ b/test/test_gem_compatibility.rb @@ -0,0 +1,87 @@ +# 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 + ].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(["localhost:#{port}", "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