From 6d3e35b0658a663a7098dae5654e04a8a0b1319b Mon Sep 17 00:00:00 2001 From: nick evans Date: Thu, 12 Feb 2026 10:09:59 -0500 Subject: [PATCH 01/10] =?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..03510b83 --- /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 and adapted from the polyfill-data gem. +# +# MIT License +# +# Copyright (c) 2023-2026 Jim Gay, nicholas a. evans, et al +# +# 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 86bf74978c6499a4d55ce9db99569f2d529dc50c Mon Sep 17 00:00:00 2001 From: nick evans Date: Sun, 19 Jul 2026 12:26:04 -0400 Subject: [PATCH 02/10] =?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 d23316c9d2c0e056503c0ce90d6fedd4db93c0f4 Mon Sep 17 00:00:00 2001 From: nick evans Date: Sun, 19 Jul 2026 18:16:21 -0400 Subject: [PATCH 03/10] =?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 6c20185cceaca164c06bbf48de9bcb89f1fe969a Mon Sep 17 00:00:00 2001 From: nick evans Date: Sat, 8 Aug 2026 17:59:11 -0400 Subject: [PATCH 04/10] =?UTF-8?q?=E2=9C=85=20Ensure=20Data=20polyfill=20te?= =?UTF-8?q?sts=20don't=20break=20ruby=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any errors will be converted to pending for CRuby >= 4.1.0. For stable CRuby versions, these will be allowed to fail. But, stable versions should be stable. --- test/net/imap/test_data_polyfill.rb | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/net/imap/test_data_polyfill.rb b/test/net/imap/test_data_polyfill.rb index 03510b83..91089801 100644 --- a/test/net/imap/test_data_polyfill.rb +++ b/test/net/imap/test_data_polyfill.rb @@ -36,6 +36,25 @@ class TestData < Net::IMAP::TestCase test "CRuby uses core ::Data" do assert_same(::Data, ::Net::IMAP::Data) end + + # These tests are not allow to break CI for ruby-head. + # Any StandardError will be converted to pending. + if RUBY_VERSION >= "4.1.0" + # test-unit doesn't have an "around callback" like rspec. + private def run_test + super + rescue Test::Unit::AssertionFailedError => error + raise Test::Unit::PendedError, + "Did this change for ruby 4.1?\n#{error.message}", + error.backtrace_locations || error.backtrace, + cause: error + rescue => error + raise Test::Unit::PendedError, + "Did this change for ruby 4.1? #{error.detailed_message}", + error.backtrace_locations || error.backtrace, + cause: error + end + end end def test_define From cc6a96aaeab87395b1730867b0b55165a5fea441 Mon Sep 17 00:00:00 2001 From: nick evans Date: Sun, 19 Jul 2026 12:34:27 -0400 Subject: [PATCH 05/10] =?UTF-8?q?=E2=9C=85=20Fix=20JRuby=20local=20backtra?= =?UTF-8?q?ce=20test=20assertions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bug was fixed in jruby-head, but that may not be in current releases: * JRuby Issue: jruby/jruby#9528 * Fixed by: jruby/jruby#9528 With that issue fixed, these tests don't need to be marked pending. BUT, JRuby _does_ still have some incongruity between `caller(1)` and `raise rescue $!.backtrace[1..]`. Some ruby block stack frames in `caller` are replaced by java stack frames in `Exception#backtrace`. For example: ```diff --- Kernel#caller +++ Exception#backtrace /home/nick/.local/share/rubies/jruby-dev/lib/ruby/gems/shared/gems/test-unit-3.7.8/lib/test/unit/testcase.rb:632:in 'block in run' - /home/nick/.local/share/rubies/jruby-dev/lib/ruby/gems/shared/gems/test-unit-3.7.8/lib/test/unit/testcase.rb:631:in 'catch' + org/jruby/RubyKernel.java:1604:in 'catch' + org/jruby/RubyKernel.java:1599:in 'catch' /home/nick/.local/share/rubies/jruby-dev/lib/ruby/gems/shared/gems/test-unit-3.7.8/lib/test/unit/testcase.rb:631:in 'run' ``` The workaround is relatively simple: use a locally generated exception to generate the stack frames for comparison. --- test/lib/helper.rb | 18 ++++++++++++++++-- test/net/imap/test_imap_tls.rb | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/test/lib/helper.rb b/test/lib/helper.rb index fe11727d..e9025fc6 100644 --- a/test/lib/helper.rb +++ b/test/lib/helper.rb @@ -224,11 +224,24 @@ def assert_local_raise(expected, message = nil) else assert_raise(expected, &block) end - stack = caller - assert_equal stack, error.backtrace&.last(stack.size) + assert_local_backtrace error error end + # Asserts that +error+ was raised in the same thread as +caller+ _and_ was + # called from the same level as +caller+. The caller's own frame is ignored, + # as are all extra frames in +error+, but the remaining frames much match. + # + # NOTE: `stack = caller(2)` is different from `$!.backtrace[2..]` in JRuby. + # Rather than use `caller`, this raises a local exception to use its backtrace + # for the comparison. + def assert_local_backtrace(error) + local_stack = raise "generating local backtrace" rescue $!.backtrace[2..] + error_stack = error.backtrace&.last(local_stack.size) + assert_equal local_stack, error_stack + error_stack + end + # Combines +assert_local_raise+ with an assertion that the exception's cause # is in the receiver thread. # @@ -236,6 +249,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) diff --git a/test/net/imap/test_imap_tls.rb b/test/net/imap/test_imap_tls.rb index 052c114e..0fbceed8 100644 --- a/test/net/imap/test_imap_tls.rb +++ b/test/net/imap/test_imap_tls.rb @@ -110,7 +110,7 @@ def test_starttls_unknown_ca imap end assert_kind_of(OpenSSL::SSL::SSLError, ex) - assert_equal (stack = caller), ex.backtrace&.last(stack.size) + assert_local_backtrace ex assert_equal false, imap.tls_verified? assert_equal({}, imap.ssl_ctx_params) assert_equal(nil, imap.ssl_ctx.ca_file) From f8948458553233c35d7ad605f5250d0d9c4c3ed1 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 3 Aug 2026 11:13:26 -0400 Subject: [PATCH 06/10] =?UTF-8?q?=E2=9C=85=F0=9F=9A=A7=20Omit=20test=20wit?= =?UTF-8?q?h=20ObjectSpace.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 95d61c11..4ffd412a 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -204,6 +204,10 @@ def @sock.shutdown(*args) 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 d142139923992d3da5557ad166e21b3dd1e49246 Mon Sep 17 00:00:00 2001 From: nick evans Date: Wed, 7 May 2025 16:38:05 -0400 Subject: [PATCH 07/10] =?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. I can confirm that these tests pass consistently for me now. --- 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 From 3a7f16c72d7d4081ad2aa5ee9b8d63f984f970c5 Mon Sep 17 00:00:00 2001 From: nick evans Date: Sat, 8 Aug 2026 14:45:12 -0400 Subject: [PATCH 08/10] =?UTF-8?q?=E2=9C=85=20Simplify=20pending=20message?= =?UTF-8?q?=20for=20non-CRuby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While it's occassionally nice during debugging to see the entire regexp, the test name is already part of the pending output. And some of the regexps are BIG. --- test/net/imap/test_regexps.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/net/imap/test_regexps.rb b/test/net/imap/test_regexps.rb index 2f06ab9c..12cae8f6 100644 --- a/test/net/imap/test_regexps.rb +++ b/test/net/imap/test_regexps.rb @@ -38,7 +38,7 @@ def test_linear_time(data) pend "Regexp.linear_time? not implemented by #{RUBY_ENGINE} #{RUBY_ENGINE_VERSION}" rescue Test::Unit::AssertionFailedError raise if RUBY_ENGINE == "ruby" - pend "%p might backtrack in %s %s" % [regexp, RUBY_ENGINE, RUBY_ENGINE_VERSION] + pend "might backtrack in %s %s" % [RUBY_ENGINE, RUBY_ENGINE_VERSION] end end From 7460808f4f0c53ed5a7ef5605bd70eb353d9f90f Mon Sep 17 00:00:00 2001 From: nick evans Date: Sat, 8 Aug 2026 18:17:55 -0400 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=A5=85=20Work=20around=20JRuby=20IO?= =?UTF-8?q?#close=20thread-safety=20issue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In CRuby, the same IO object generally can't be closed concurrently from multiple threads thanks to the GVL. But also, it checks and double checks whether or not the IO object has already been closed around critical sections, and simply returns if it's already been closed. JRuby seems to handle concurrent `IO#close` similarly to if the losing thread were trying to read or write. So it can easily be triggered into raise an IOError with "closed stream". --- lib/net/imap.rb | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 166b7179..46bd19b4 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -1215,10 +1215,20 @@ def disconnect(timeout: nil) @sock.to_io.shutdown rescue Errno::ENOTCONN # ignore `Errno::ENOTCONN: Socket is not connected' on some platforms. + rescue IOError => e + # IO#close should be safe against being closed by another thread, but + # JRuby raises this error sometimes. + raise unless e.message == "closed stream" rescue Exception => e @receiver_thread.raise(e) unless in_receiver_thread end - @sock.close + begin + @sock.close + rescue IOError => e + # IO#close should be safe against being closed by another thread, but + # JRuby raises this error sometimes. + raise unless e.message == "closed stream" + end @receiver_thread.join(timeout) unless mon_owned? || in_receiver_thread raise e if e ensure From 6d7c244b019bb3d83b5d4a3f9c4321db8c401e11 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 5 May 2025 15:22:03 -0400 Subject: [PATCH 10/10] =?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