From 8db0ce18194a1ed0a04bcf6f27265866437ee17e Mon Sep 17 00:00:00 2001 From: nick evans Date: Thu, 12 Feb 2026 10:09:59 -0500 Subject: [PATCH 01/11] =?UTF-8?q?=E2=8F=AA=F0=9F=9A=A7=20Bring=20back=20Da?= =?UTF-8?q?ta=20polyfill=20for=20JRuby,=20TruffleRuby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Data polyfill was removed in v0.6.0, but I'm bringing it back so we can work around JRuby and TruffleRuby test failures. The polyfill is _only_ loaded when RUBY_ENGINE is either jruby or truffleruby. Even for those ruby engines, several compatibility tests are run, and the polyfill is only loaded if one of those tests fail. Some related issues and PRs (which may have already been fixed by the time this is merged): * jruby/jruby#8829 * jruby/jruby#9530 * oracle/truffleruby#3846 * oracle/truffleruby#3847 --- lib/net/imap.rb | 3 + lib/net/imap/data_polyfill.rb | 244 +++++++++++++++++ test/net/imap/test_data_polyfill.rb | 390 ++++++++++++++++++++++++++++ 3 files changed, 637 insertions(+) create mode 100644 lib/net/imap/data_polyfill.rb create mode 100644 test/net/imap/test_data_polyfill.rb diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 18868b47..166b7179 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -4072,6 +4072,9 @@ def self.saslprep(string, **opts) end end +# TODO: remove after TruffleRuby and JRuby bugs are fixed +require_relative "imap/data_polyfill" + require_relative "imap/errors" require_relative "imap/config" require_relative "imap/command_data" diff --git a/lib/net/imap/data_polyfill.rb b/lib/net/imap/data_polyfill.rb new file mode 100644 index 00000000..48e56db2 --- /dev/null +++ b/lib/net/imap/data_polyfill.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +# Some of the code in this file was copied from the polyfill-data gem. +# +# MIT License +# +# Copyright (c) 2023 Jim Gay, Joel Drapper, Nicholas Evans +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +# simplecov:disable -- Skip test coverage for this file + +unless RUBY_ENGINE in "jruby" | "truffleruby" + Net::IMAP::Data = ::Data + return # don't load the rest of the file +end +begin + module TestData + Incompatible = Class.new(StandardError) + + class Abstract < ::Data + def self.class_method = :ok + def deconstruct = [:ok, *super] + end + + class Inherits < Abstract.define(:foo) + end + + errors = [] + begin + data = Inherits.new(1) + data.foo.nil? and errors << "member attribute" + data.deconstruct in :ok, 1 or errors << "inherited #deconstruct" + Inherits.class_method rescue errors << "inherited class method" + rescue => error + errors << error.detailed_message(highlight: false, did_you_mean: false) + end + raise Incompatible, errors.join(", ") if errors.any? + end +rescue TestData::Incompatible => err + warn "WARN: Net::IMAP detected incompatible Data implementation: #{err}." +else + Net::IMAP::Data = ::Data + return # don't load the rest of the file +ensure + Object.module_exec do remove_const :TestData end if defined?(TestData) +end + +warn "WARN: Using Net::IMAP::Data polyfill." + +module Net + class IMAP + # DataPolyfill is a temporary substitute for ruby 3.2's +Data+ class, but it + # is only loaded by JRuby and TruffleRuby. The core +Data+ class is + # tested, and when it is sufficiently compatible with CRuby, DataPolyfill + # won't be loaded. + # + # DataPolyfill is aliased as Net::IMAP::Data, so that Net::IMAP code can use + # it as +Data+. + # + # See {ruby 3.2's documentation for Data}[https://docs.ruby-lang.org/en/3.2/Data.html]. + # + # Some of the code in this class was copied or adapted from the + # {polyfill-data gem}[https://rubygems.org/gems/polyfill-data], by Jim Gay + # and Joel Drapper, under the MIT license terms. + class DataPolyfill + singleton_class.undef_method :new + + TYPE_ERROR = "%p is not a symbol nor a string" + ATTRSET_ERROR = "invalid data member: %p" + DUP_ERROR = "duplicate member: %p" + ARITY_ERROR = "wrong number of arguments (given %d, expected %s)" + private_constant :TYPE_ERROR, :ATTRSET_ERROR, :DUP_ERROR, :ARITY_ERROR + + # Defines a new Data class. + # + # _NOTE:_ Unlike ruby 3.2's +Data.define+, DataPolyfill.define only + # supports member names which are valid local variable names. Member + # names can't be keywords (e.g: +next+ or +class+) or start with capital + # letters, "@", etc. + def self.define(*args, &block) + members = args.each_with_object({}) do |arg, members| + arg = arg.to_str unless arg in Symbol | String if arg.respond_to?(:to_str) + arg = arg.to_sym if arg in String + arg in Symbol or raise TypeError, TYPE_ERROR % [arg] + arg in %r{=} and raise ArgumentError, ATTRSET_ERROR % [arg] + members.key?(arg) and raise ArgumentError, DUP_ERROR % [arg] + members[arg] = true + end + members = members.keys.freeze + + klass = ::Class.new(self) + + klass.singleton_class.undef_method :define + klass.define_singleton_method(:members) { members } + + def klass.new(*args, **kwargs, &block) + if kwargs.size.positive? + if args.size.positive? + raise ArgumentError, ARITY_ERROR % [args.size, 0] + end + elsif members.size < args.size + expected = members.size.zero? ? 0 : 0..members.size + raise ArgumentError, ARITY_ERROR % [args.size, expected] + else + kwargs = Hash[members.take(args.size).zip(args)] + end + allocate.tap do |instance| + instance.__send__(:initialize, **kwargs, &block) + end.freeze + end + + klass.singleton_class.alias_method :[], :new + klass.attr_reader(*members) + + # Dynamically defined initializer methods are in an included module, + # rather than directly on DataPolyfill (like in ruby 3.2+): + # * simpler to handle required kwarg ArgumentErrors + # * easier to ensure consistent ivar assignment order (object shape) + # * faster than instance_variable_set + klass.include(Module.new do + if members.any? + kwargs = members.map{"#{_1.name}:"}.join(", ") + params = members.map(&:name).join(", ") + ivars = members.map{"@#{_1.name}"}.join(", ") + attrs = members.map{"attrs[:#{_1.name}]"}.join(", ") + module_eval <<~RUBY, __FILE__, __LINE__ + 1 + protected + def initialize(#{kwargs}) #{ivars} = #{params}; freeze end + def marshal_load(attrs) #{ivars} = #{attrs}; freeze end + RUBY + end + end) + + klass.module_eval do _1.module_eval(&block) end if block_given? + + klass + end + + ## + # singleton-method: new + # call-seq: + # new(*args) -> instance + # new(**kwargs) -> instance + # + # Constuctor for classes defined with ::define. + # + # Aliased as ::[]. + + ## + # singleton-method: [] + # call-seq: + # ::[](*args) -> instance + # ::[](**kwargs) -> instance + # + # Constuctor for classes defined with ::define. + # + # Alias for ::new + + ## + def members; self.class.members end + def to_h(&block) block ? __to_h__.to_h(&block) : __to_h__ end + def hash; [self.class, __to_h__].hash end + def ==(other) self.class == other.class && to_h == other.to_h end + def eql?(other) self.class == other.class && hash == other.hash end + def deconstruct; __to_h__.values end + + def deconstruct_keys(keys) + raise TypeError unless keys.is_a?(Array) || keys.nil? + return __to_h__ if keys&.first.nil? + __to_h__.slice(*keys) + end + + def with(**kwargs) + return self if kwargs.empty? + self.class.new(**__to_h__.merge(kwargs)) + end + + def inspect + __inspect_guard__(self) do |seen| + return "#" if seen + attrs = __to_h__.map {|kv| "%s=%p" % kv }.join(", ") + display = ["data", self.class.name, attrs].compact.join(" ") + "#<#{display}>" + end + end + alias_method :to_s, :inspect + + private + + def initialize_copy(source) super.freeze end + def marshal_dump; __to_h__ end + + def __to_h__; Hash[members.map {|m| [m, send(m)] }] end + + # Yields +true+ if +obj+ has been seen already, +false+ if it hasn't. + # Marks +obj+ as seen inside the block, so circuler references don't + # recursively trigger a SystemStackError (stack level too deep). + # + # Making circular references inside a Data object _should_ be very + # uncommon, but we'll support them for the sake of completeness. + def __inspect_guard__(obj) + preexisting = Thread.current[:__net_imap_data__inspect__] + Thread.current[:__net_imap_data__inspect__] ||= {}.compare_by_identity + inspect_guard = Thread.current[:__net_imap_data__inspect__] + if inspect_guard.include?(obj) + yield true + else + begin + inspect_guard[obj] = true + yield false + ensure + inspect_guard.delete(obj) + end + end + ensure + unless preexisting.equal?(inspect_guard) + Thread.current[:__net_imap_data__inspect__] = preexisting + end + end + + end + + Data = DataPolyfill + end +end + +# simplecov:enable diff --git a/test/net/imap/test_data_polyfill.rb b/test/net/imap/test_data_polyfill.rb new file mode 100644 index 00000000..85c6c427 --- /dev/null +++ b/test/net/imap/test_data_polyfill.rb @@ -0,0 +1,390 @@ +# frozen_string_literal: false + +require "net/imap" +require "test/unit" + +# This test file was copied from the polyfill-data gem. +# +# MIT License +# +# Copyright (c) 2023 Jim Gay +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +module Net + class IMAP + class TestData < Net::IMAP::TestCase + Data = Net::IMAP::Data + + if RUBY_ENGINE == "ruby" + test "CRuby uses core ::Data" do + assert_same(::Data, ::Net::IMAP::Data) + end + end + + def test_define + klass = Data.define(:foo, :bar) + assert_kind_of(Class, klass) + assert_equal(%i[foo bar], klass.members) + + assert_raise(NoMethodError) { Data.new(:foo) } + assert_raise(TypeError) { Data.define(0) } + + # Because some code is shared with Struct, check we don't share unnecessary functionality + assert_raise(TypeError) { Data.define(:foo, keyword_init: true) } + + refute_respond_to(Data.define, :define, "Cannot define from defined Data class") + end + + def test_define_edge_cases + # non-ascii + klass = Data.define(:"r\u{e9}sum\u{e9}") + o = klass.new(1) + assert_equal(1, o.send(:"r\u{e9}sum\u{e9}")) + + assert_raise(ArgumentError) { Data.define(:x=) } + assert_raise(ArgumentError, /duplicate member/) { Data.define(:x, :x) } + end + + def test_define_with_block + klass = Data.define(:a, :b) do + def c + a + b + end + end + + assert_equal(3, klass.new(1, 2).c) + end + + def test_initialize + klass = Data.define(:foo, :bar) + + # Regular + test = klass.new(1, 2) + assert_equal(1, test.foo) + assert_equal(2, test.bar) + assert_equal(test, klass.new(1, 2)) + assert_predicate(test, :frozen?) + + # Keywords + test_kw = klass.new(foo: 1, bar: 2) + assert_equal(1, test_kw.foo) + assert_equal(2, test_kw.bar) + assert_equal(test_kw, klass.new(foo: 1, bar: 2)) + assert_equal(test_kw, test) + + # Wrong protocol + assert_raise(ArgumentError) { klass.new(1) } + assert_raise(ArgumentError) { klass.new(1, 2, 3) } + assert_raise(ArgumentError) { klass.new(foo: 1) } + assert_raise(ArgumentError) { klass.new(foo: 1, bar: 2, baz: 3) } + # Could be converted to foo: 1, bar: 2, but too smart is confusing + assert_raise(ArgumentError) { klass.new(1, bar: 2) } + end + + def test_initialize_redefine + klass = Data.define(:foo, :bar) do + attr_reader :passed + + def initialize(*args, **kwargs) + @passed = [args, kwargs] + + super(foo: 1, bar: 2) # so we can experiment with passing wrong numbers of args + end + end + + assert_equal([[], {foo: 1, bar: 2}], klass.new(foo: 1, bar: 2).passed) + + # Positional arguments are converted to keyword ones + assert_equal([[], {foo: 1, bar: 2}], klass.new(1, 2).passed) + + # Missing arguments can be fixed in initialize + assert_equal([[], {foo: 1}], klass.new(foo: 1).passed) + + # Extra keyword arguments can be dropped in initialize + assert_equal([[], {foo: 1, bar: 2, baz: 3}], klass.new(foo: 1, bar: 2, baz: 3).passed) + end + + def test_instance_behavior + klass = Data.define(:foo, :bar) + + test = klass.new(1, 2) + assert_equal(1, test.foo) + assert_equal(2, test.bar) + assert_equal(%i[foo bar], test.members) + assert_equal(1, test.public_send(:foo)) + assert_equal(0, test.method(:foo).arity) + assert_equal([], test.method(:foo).parameters) + + assert_equal({foo: 1, bar: 2}, test.to_h) + assert_equal({"foo"=>"1", "bar"=>"2"}, test.to_h { [_1.to_s, _2.to_s] }) + + assert_equal({foo: 1, bar: 2}, test.deconstruct_keys(nil)) + assert_equal({foo: 1}, test.deconstruct_keys(%i[foo])) + assert_equal({foo: 1}, test.deconstruct_keys(%i[foo baz])) + assert_raise(TypeError) { test.deconstruct_keys(0) } + + test = klass.new(bar: 2, foo: 1) + assert_equal([1, 2], test.deconstruct) + + assert_kind_of(Integer, test.hash) + end + + def test_inspect + klass = Data.define(:a) + o = klass.new(1) + assert_equal("#", o.inspect) + + Object.const_set(:Foo, klass) + assert_equal("#", o.inspect) + Object.instance_eval { remove_const(:Foo) } + + klass = Data.define(:one, :two) + o = klass.new(1,2) + assert_equal("#", o.inspect) + assert_equal("#", o.to_s) + end + + def test_recursive_inspect + klass = Data.define(:value, :head, :tail) do + def initialize(value:, head: nil, tail: nil) + case tail + in Array if tail.empty? + tail = nil + in Array + succ, *rest = *tail + tail = self.class[head: self, value: succ, tail: rest] + in [tailprev, _, _] if tail.class == self.class && tailprev == self + # noop + in [tailprev, succ, rest] if tail.class == self.class + tail = self.class[head: self, value: succ, tail: rest] + in nil + else + tail = self.class[head: self, value: tail, tail: nil] + end + super(head:, value:, tail:) + end + end + + # anonymous class + list = klass[value: 1, tail: [2, 3, 4]] + seen = "#" + assert_equal( + "#>>>", + list.inspect + ) + + # named class + Object.const_set(:DoubleLinkList, klass) + list = DoubleLinkList[value: 1, tail: [2, 3, 4]] + seen = "#" + assert_equal( + "#>>>", + list.inspect + ) + ensure + Object.instance_eval { remove_const(:DoubleLinkList) } rescue nil + end + + def test_equal + klass1 = Data.define(:a) + klass2 = Data.define(:a) + o1 = klass1.new(1) + o2 = klass1.new(1) + o3 = klass2.new(1) + assert_equal(o1, o2) + refute_equal(o1, o3) + end + + def test_eql + klass1 = Data.define(:a) + klass2 = Data.define(:a) + o1 = klass1.new(1) + o2 = klass1.new(1) + o3 = klass2.new(1) + assert_operator(o1, :eql?, o2) + refute_operator(o1, :eql?, o3) + end + + def test_with + klass = Data.define(:foo, :bar) + source = klass.new(foo: 1, bar: 2) + + # Simple + test = source.with + assert_equal(source.object_id, test.object_id) + + # Changes + test = source.with(foo: 10) + + assert_equal(1, source.foo) + assert_equal(2, source.bar) + assert_equal(source, klass.new(foo: 1, bar: 2)) + + assert_equal(10, test.foo) + assert_equal(2, test.bar) + assert_equal(test, klass.new(foo: 10, bar: 2)) + + test = source.with(foo: 10, bar: 20) + + assert_equal(1, source.foo) + assert_equal(2, source.bar) + assert_equal(source, klass.new(foo: 1, bar: 2)) + + assert_equal(10, test.foo) + assert_equal(20, test.bar) + assert_equal(test, klass.new(foo: 10, bar: 20)) + + # Keyword splat + changes = { foo: 10, bar: 20 } + test = source.with(**changes) + + assert_equal(1, source.foo) + assert_equal(2, source.bar) + assert_equal(source, klass.new(foo: 1, bar: 2)) + + assert_equal(10, test.foo) + assert_equal(20, test.bar) + assert_equal(test, klass.new(foo: 10, bar: 20)) + + # Wrong protocol + assert_raise(ArgumentError, "wrong number of arguments (given 1, expected 0)") do + source.with(10) + end + assert_raise(ArgumentError, "unknown keywords: :baz, :quux") do + source.with(foo: 1, bar: 2, baz: 3, quux: 4) + end + assert_raise(ArgumentError, "wrong number of arguments (given 1, expected 0)") do + source.with(1, bar: 2) + end + assert_raise(ArgumentError, "wrong number of arguments (given 2, expected 0)") do + source.with(1, 2) + end + assert_raise(ArgumentError, "wrong number of arguments (given 1, expected 0)") do + source.with({ bar: 2 }) + end unless RUBY_VERSION < "2.8.0" + end + + def test_memberless + klass = Data.define + + test = klass.new + + assert_equal(klass.new, test) + refute_equal(Data.define.new, test) + + assert_match(/#/, test.inspect) + assert_equal([], test.members) + assert_equal({}, test.to_h) + end + + def test_square_braces + klass = Data.define(:amount, :unit) + + distance = klass[10, 'km'] + + assert_equal(10, distance.amount) + assert_equal('km', distance.unit) + end + + def test_dup + klass = Data.define(:foo, :bar) + test = klass.new(foo: 1, bar: 2) + assert_equal(klass.new(foo: 1, bar: 2), test.dup) + assert_predicate(test.dup, :frozen?) + end + + Klass = Data.define(:foo, :bar) + + def test_marshal + test = Klass.new(foo: 1, bar: 2) + loaded = Marshal.load(Marshal.dump(test)) + assert_equal(test, loaded) + refute_same(test, loaded) + assert_predicate(loaded, :frozen?) + end + + def test_member_precedence + name_mod = Module.new do + def name + "default name" + end + + def other + "other" + end + end + + klass = Data.define(:name) do + include name_mod + end + + data = klass.new("test") + + assert_equal("test", data.name) + assert_equal("other", data.other) + end + + class Abstract < Data + end + + class Inherited < Abstract.define(:foo) + end + + def test_subclass_can_create + assert_equal 1, Inherited[1].foo + assert_equal 2, Inherited[foo: 2].foo + assert_equal 3, Inherited.new(3).foo + assert_equal 4, Inherited.new(foo: 4).foo + end + + class AbstractWithClassMethod < Data + def self.inherited_class_method; :ok end + end + + class InheritsClassMethod < AbstractWithClassMethod.define(:foo) + end + + def test_subclass_class_method + assert_equal :ok, InheritsClassMethod.inherited_class_method + end + + class AbstractWithOverride < Data + def deconstruct; [:ok, *super] end + end + + class InheritsOverride < AbstractWithOverride.define(:foo) + end + + def test_subclass_override_deconstruct + data = InheritsOverride[:foo] + assert_equal %i[ok foo], data.deconstruct + end + + end + end +end From ce0abfacacabd4bfa40bfde7c16221205e149ff4 Mon Sep 17 00:00:00 2001 From: nick evans Date: Sun, 19 Jul 2026 12:26:04 -0400 Subject: [PATCH 02/11] =?UTF-8?q?=E2=9C=85=20Explicitly=20add=20Data=20pol?= =?UTF-8?q?yfill=20to=20Psych.load=5Ftags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, `!ruby/data:Net::IMAP::#{classname}` objects won't load. --- test/net/imap/net_imap_test_helpers.rb | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/test/net/imap/net_imap_test_helpers.rb b/test/net/imap/net_imap_test_helpers.rb index d7ecac23..f2675548 100644 --- a/test/net/imap/net_imap_test_helpers.rb +++ b/test/net/imap/net_imap_test_helpers.rb @@ -9,9 +9,22 @@ module TestFixtureGenerators attr_reader :fixtures + # TODO: remove this once TruffleRuby and JRuby are compatible + if Net::IMAP::Data != ::Data + Dir["test/net/imap/fixtures/response_parser/*.yml"].lazy + .flat_map { File.readlines(_1) } + .filter_map { %r{!ruby/data:(Net::IMAP::(?:\w|:)+)}.match _1 and $1 } + .each do |name| + Psych.load_tags["!ruby/data:#{name}"] ||= name + end + end + def load_fixture_data(*test_fixture_path) dir = self::TEST_FIXTURE_PATH YAML.unsafe_load_file File.join(dir, *test_fixture_path) + rescue + warn "⚠️ Couldn't load test fixture: #{test_fixture_path.last}" + raise end def generate_tests_from(fixture_data: nil, fixture_file: nil) From de0a4318e02c7f60a524c90d2eeed8922c913234 Mon Sep 17 00:00:00 2001 From: nick evans Date: Sun, 19 Jul 2026 12:31:54 -0400 Subject: [PATCH 03/11] =?UTF-8?q?=F0=9F=A9=B9=20Workaround=20JRuby=20bug?= =?UTF-8?q?=20NoMatchingPatternError#=3D=3D=3D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For some unknown reason, JRuby's `NoMatchingPatternKeyError#===` doesn't match `NoMatchingPatternKeyError` objects. See also: * jruby/jruby/#9537 --- lib/net/imap/errors.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/net/imap/errors.rb b/lib/net/imap/errors.rb index a042604e..0475e10d 100644 --- a/lib/net/imap/errors.rb +++ b/lib/net/imap/errors.rb @@ -340,7 +340,7 @@ def initialize(state, response:, command: nil) @state, @command, @response = state, command, response cmd_desc = name ? "#{state} #{name}" : state super "Received tagged #{status} to #{cmd_desc} command (tag=#{tag})" - rescue NoMatchingPatternError => err + rescue NoMatchingPatternError, NoMatchingPatternKeyError => err raise ArgumentError, err.message end From d60222ac82fbe59daa9bb0a0e1f78507b4a1662a Mon Sep 17 00:00:00 2001 From: nick evans Date: Sun, 19 Jul 2026 18:16:21 -0400 Subject: [PATCH 04/11] =?UTF-8?q?=E2=9C=85=20Un-pend=20TruffleRuby=20tests?= =?UTF-8?q?=20using=20Data-polyfill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/net/imap/test_connection_state.rb | 28 +++++++++++--------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/test/net/imap/test_connection_state.rb b/test/net/imap/test_connection_state.rb index ec6b11eb..8309de5d 100644 --- a/test/net/imap/test_connection_state.rb +++ b/test/net/imap/test_connection_state.rb @@ -25,25 +25,21 @@ class ConnectionStateTest < Net::IMAP::TestCase end test "#deconstruct" do - pend_if_truffleruby "TruffleRuby bug overriding ::Data methods" do - assert_equal [:not_authenticated], NotAuthenticated[].deconstruct - assert_equal [:authenticated], Authenticated[] .deconstruct - assert_equal [:selected], Selected[] .deconstruct - assert_equal [:logout], Logout[] .deconstruct - end + assert_equal [:not_authenticated], NotAuthenticated[].deconstruct + assert_equal [:authenticated], Authenticated[] .deconstruct + assert_equal [:selected], Selected[] .deconstruct + assert_equal [:logout], Logout[] .deconstruct end test "#deconstruct_keys" do - pend_if_truffleruby "TruffleRuby bug overriding ::Data methods" do - assert_equal({symbol: :not_authenticated}, NotAuthenticated[].deconstruct_keys([:symbol])) - assert_equal({symbol: :authenticated}, Authenticated[] .deconstruct_keys([:symbol])) - assert_equal({symbol: :selected}, Selected[] .deconstruct_keys([:symbol])) - assert_equal({symbol: :logout}, Logout[] .deconstruct_keys([:symbol])) - assert_equal({name: "not_authenticated"}, NotAuthenticated[].deconstruct_keys([:name])) - assert_equal({name: "authenticated"}, Authenticated[] .deconstruct_keys([:name])) - assert_equal({name: "selected"}, Selected[] .deconstruct_keys([:name])) - assert_equal({name: "logout"}, Logout[] .deconstruct_keys([:name])) - end + assert_equal({symbol: :not_authenticated}, NotAuthenticated[].deconstruct_keys([:symbol])) + assert_equal({symbol: :authenticated}, Authenticated[] .deconstruct_keys([:symbol])) + assert_equal({symbol: :selected}, Selected[] .deconstruct_keys([:symbol])) + assert_equal({symbol: :logout}, Logout[] .deconstruct_keys([:symbol])) + assert_equal({name: "not_authenticated"}, NotAuthenticated[].deconstruct_keys([:name])) + assert_equal({name: "authenticated"}, Authenticated[] .deconstruct_keys([:name])) + assert_equal({name: "selected"}, Selected[] .deconstruct_keys([:name])) + assert_equal({name: "logout"}, Logout[] .deconstruct_keys([:name])) end test "#not_authenticated?" do From f370568c94ceffe649ba8c5decf6010608ec2c52 Mon Sep 17 00:00:00 2001 From: nick evans Date: Sun, 19 Jul 2026 12:16:05 -0400 Subject: [PATCH 05/11] =?UTF-8?q?=E2=9C=85=F0=9F=9A=A7=20Omit=20ResponsePa?= =?UTF-8?q?rseError=20test=20for=20JRuby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruby spec for this (https://github.com/ruby/spec/commit/d750b85f) did not specify that `default_proc` is called with the _correct args!_ See the JRuby issue here: jruby/jruby#9558. ```ruby h = Hash.new {|*args| "args=%p" % [args] } pp h[:foo] pp "%{foo}" % h ``` For CRuby (and TruffleRuby), we get the expected results: ``` "args=[{}, :foo]" "args=[{}, :foo]" ``` For JRuby, we see that only the key is passed: ``` "args=[{}, :foo]" "args=[:foo]" ``` --- test/net/imap/test_errors.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/net/imap/test_errors.rb b/test/net/imap/test_errors.rb index 6a07a7fb..e3519fca 100644 --- a/test/net/imap/test_errors.rb +++ b/test/net/imap/test_errors.rb @@ -107,6 +107,7 @@ def self.SGR(*attr) = CSI attr.join(?;), ?m ENV["TERM"], ENV["NO_COLOR"], ENV["FORCE_COLOR"] = nil, nil, "0" assert_equal(expected_no_hl, err.detailed_message) + omit_if_jruby "JRuby sprintf calls Hash#default_proc incorrectly (see jruby/jruby#9558)" assert_equal(expected_color_hl, err.detailed_message(highlight: true)) assert_equal(expected_no_color, err.detailed_message(highlight: true, highlight_no_color: true)) From 17c9b3d04455cec88cb5249c95c94da17c97b89a Mon Sep 17 00:00:00 2001 From: nick evans Date: Sun, 19 Jul 2026 12:34:27 -0400 Subject: [PATCH 06/11] =?UTF-8?q?=E2=9C=85=F0=9F=9A=A7=20Mark=20local=20st?= =?UTF-8?q?acktrace=20tests=20pending=20for=20JRuby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stacktraces just don't line up. * JRuby Issue: jruby/jruby#9528 * Fixed by: jruby/jruby#9528 --- test/lib/helper.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/lib/helper.rb b/test/lib/helper.rb index 95865637..95879010 100644 --- a/test/lib/helper.rb +++ b/test/lib/helper.rb @@ -210,7 +210,9 @@ def assert_local_raise(expected, message = nil) assert_raise(expected, &block) end stack = caller - assert_equal stack, error.backtrace&.last(stack.size) + pend_if_jruby("JRuby bug: jruby/jruby#9528, fixed by: jruby/jruby#9548") do + assert_equal stack, error.backtrace&.last(stack.size) + end error end @@ -221,6 +223,7 @@ def assert_local_raise(expected, message = nil) # it can capture the top of the stacktrace. After that, it'll continue to use # the same stacktrace. def assert_reraised(*args, imap: nil, &block) + return assert_local_raise(*args, &block) if RUBY_ENGINE == "jruby" @rcvr_thread_trace ||= imap.instance_variable_get(:@receiver_thread) &.backtrace&.last(2) error = assert_local_raise(*args, &block) From bbd62967c7fbf20b081a911b90f3869ecea539b8 Mon Sep 17 00:00:00 2001 From: nick evans Date: Wed, 7 May 2025 16:38:05 -0400 Subject: [PATCH 07/11] =?UTF-8?q?=E2=9C=85=F0=9F=9A=A7=20Omit=20several=20?= =?UTF-8?q?SSL=20connection=20tests=20for=20JRuby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See also: * jruby/jruby#9529 --- test/net/imap/test_imap.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index b178681c..d8a52242 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -51,6 +51,9 @@ def test_imaps_with_ca_file assert_equal(CA_FILE, imap.ssl_ctx.ca_file) assert_equal(OpenSSL::SSL::VERIFY_PEER, imap.ssl_ctx.verify_mode) assert imap.ssl_ctx.verify_hostname + rescue IOError + omit_if_jruby "JRuby sometimes fails with #" + raise end def test_imaps_verify_none @@ -81,6 +84,7 @@ def test_imaps_verify_none end def test_imaps_post_connection_check + omit_if_jruby "JRuby hangs indefinitely, without raising SSLError" assert_local_raise(OpenSSL::SSL::SSLError) do imaps_test do |port| # server_addr is different from the hostname in the certificate, @@ -95,6 +99,7 @@ def test_imaps_post_connection_check if defined?(OpenSSL::SSL) def test_starttls_unknown_ca + omit_if_jruby "JRuby hangs indefinitely, without raising SSLError" omit "This test is not working with Windows" if RUBY_PLATFORM =~ /mswin|mingw/ imap = nil From a5c0719262618c21060fd41070706d0a90f8ac31 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 3 Aug 2026 11:13:26 -0400 Subject: [PATCH 08/11] =?UTF-8?q?=E2=9C=85=20Omit=20test=20with=20ObjectSp?= =?UTF-8?q?ace.each=5Fobject=20if=20JRuby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/net/imap/test_imap.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index d8a52242..c64c97c7 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -682,6 +682,10 @@ def test_connection_closed_during_idle end def test_connection_closed_without_greeting + unless (ObjectSpace.each_object(Object) { break true } rescue false) + omit_if_jruby "JRuby must enable ObjectSpace.each_object for this test" + end + server = create_tcp_server port = server.addr[1] h = { From 4e54353414b21ddffb44d01a57cd9e92756c3452 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 3 Aug 2026 11:41:31 -0400 Subject: [PATCH 09/11] =?UTF-8?q?=E2=9C=85=F0=9F=9A=A7=20Omit=20InvalidTag?= =?UTF-8?q?gedResponseError=20test=20for=20JRuby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JRuby still raises an IOError, but not the expected exception. --- test/net/imap/test_imap.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index c64c97c7..fe4406a9 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -162,6 +162,7 @@ def test_starttls_stripping_not_ok end begin imap = Net::IMAP.new("localhost", :port => port) + omit_if_jruby "JRuby sometimes raises IOError instead" assert_reraised(Net::IMAP::InvalidResponseError, imap:) do imap.starttls(:ca_file => CA_FILE) end @@ -209,6 +210,7 @@ def test_starttls_stripping_ok_sent_before_response # stored it in @tagged_responses, so finish_sending_command can see it. # (handle_response stores the tagged response before calling handlers.) rcvr_to_client.pop + omit_if_jruby "JRuby sometimes raises IOError instead" assert_local_raise(Net::IMAP::InvalidTaggedResponseError) do imap.starttls(:ca_file => CA_FILE) end @@ -265,6 +267,7 @@ def test_starttls_stripping_ok_sent_before_response assert_equal :sent_malicious_responses, server_to_client.pop assert_equal [1, 2, 3], 3.times.map { rcvr_to_client.pop } # should respond this way for _any_ command + omit_if_jruby "JRuby sometimes raises IOError instead" assert_local_raise(Net::IMAP::InvalidTaggedResponseError) do cmd.(imap) end From f537b7941fe0657d485cc0f819feb8ede592c3fc Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 5 May 2025 15:22:03 -0400 Subject: [PATCH 10/11] =?UTF-8?q?=E2=9C=85=F0=9F=9A=A7=20Run=20CI=20with?= =?UTF-8?q?=20JRuby,=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Using jruby-head to get some bugfixes. --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a59727b..7ea21002 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,7 @@ jobs: experimental: [false] include: - { ruby: truffleruby, os: ubuntu-latest, experimental: true } + - { ruby: jruby-head, os: ubuntu-latest, experimental: true } runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.experimental }} timeout-minutes: 15 From bc32187072a3b1f2093323259790a3562b84eae3 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 3 Aug 2026 14:29:36 -0400 Subject: [PATCH 11/11] =?UTF-8?q?=E2=9C=85=20Require=20jruby-openssl=20>?= =?UTF-8?q?=3D=200.19.0=20for=20JRuby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @kares reports that some OpenSSL test flakiness should be resolved by upgrading to v0.19.0. --- Gemfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Gemfile b/Gemfile index 458900cb..0d953b47 100644 --- a/Gemfile +++ b/Gemfile @@ -19,6 +19,8 @@ gem "benchmark", require: false gem "benchmark-driver", require: false gem "vernier", require: false, platform: :mri +gem "jruby-openssl", ">= 0.19.0", platform: :jruby # fixes some flaky tests + group :test do gem "simplecov", ">= 1.0.0", require: false, platforms: %i[mri windows] end