From 783dde36e9be57561329da7dd52a2f0f75710d92 Mon Sep 17 00:00:00 2001 From: Matt Valentine-House Date: Fri, 25 Sep 2026 19:22:33 +0100 Subject: [PATCH] json: reject non-shareable default sort_keys proc from non-main Ractors JSON::State.default_sort_keys_proc is registered at extension load time so it's owned by the main Ractor. When we implement the registration table on each Ractor storing another Ractor's unshareable Proc into that slot leaves a dangling reference when the owning Ractor runs a local GC. This commit ensures that we won't use a shareable proc by raising a Ractor::IsolationError. --- lib/json/common.rb | 18 ++++++++++++++++++ test/json/json_generator_test.rb | 12 ++++++++++++ test/json/ractor_test.rb | 29 +++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/lib/json/common.rb b/lib/json/common.rb index 82d334061..1c5d4c22e 100644 --- a/lib/json/common.rb +++ b/lib/json/common.rb @@ -80,6 +80,24 @@ def parser=(parser) # :nodoc: def generator=(generator) # :nodoc: old, $VERBOSE = $VERBOSE, nil + unless generator::State.respond_to?(:default_sort_keys_proc_unchecked=, true) + generator::State.singleton_class.class_eval do + alias_method :default_sort_keys_proc_unchecked=, :default_sort_keys_proc= + private :default_sort_keys_proc_unchecked= + + def default_sort_keys_proc=(proc) + unless ::Proc === proc + raise ::TypeError, "sort_key_proc must be a Proc" + end + if defined?(::Ractor) && !::Ractor.shareable?(proc) && !::Ractor.current.equal?(::Ractor.main) + raise ::Ractor::IsolationError, + "can not set a non-shareable Proc as the default sort_keys proc from a non-main Ractor" + end + self.default_sort_keys_proc_unchecked = proc + end + end + end + # The default proc used when the +sort_keys+ generation option is +true+. # It returns a new hash with the entries sorted by their keys. sort_keys_proc = ->(hash) { hash.sort.to_h } diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index 1b2197735..e86fe6a2e 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -248,6 +248,18 @@ def test_generate_sort_keys_with_proc assert_instance_of Proc, state.sort_keys end + def test_default_sort_keys_proc_setter_survives_generator_reassignment + omit "fork not supported" unless Process.respond_to?(:fork) + pid = fork do + JSON.generator = JSON::Ext::Generator + JSON.generator = JSON::Ext::Generator + JSON::State.default_sort_keys_proc = ->(hash) { hash.sort.to_h } + exit!(JSON.generate({b: 1, a: 2}, sort_keys: true) == '{"a":2,"b":1}' ? 0 : 1) + end + _, status = Process.wait2(pid) + assert_predicate status, :success? + end + def test_generate_custom state = State.new(space_before: " ", space: " ", indent: "", object_nl: "\n", array_nl: "") json = generate({1=>{2=>3,4=>[5,6]}}, state) diff --git a/test/json/ractor_test.rb b/test/json/ractor_test.rb index 9e28f13c1..a501344fa 100644 --- a/test/json/ractor_test.rb +++ b/test/json/ractor_test.rb @@ -123,4 +123,33 @@ def test_coder_proc _, status = Process.waitpid2(pid) assert_predicate status, :success? end if Ractor.respond_to?(:shareable_proc) + + def test_default_sort_keys_proc_ractor_safety + pid = fork do + Warning[:experimental] = false + results = Ractor.new do + outcomes = [] + + begin + JSON::State.default_sort_keys_proc = ->(hash) { hash.sort.to_h } + outcomes << :accepted + rescue Ractor::IsolationError + outcomes << :rejected + end + + # A shareable Proc is allowed from any Ractor. + JSON::State.default_sort_keys_proc = Ractor.shareable_lambda { |hash| hash.sort.reverse.to_h } + outcomes << JSON.generate({b: 1, a: 2}, sort_keys: true) + outcomes + end.value + + # The main Ractor owns the slot, so it may install any Proc. + JSON::State.default_sort_keys_proc = ->(hash) { hash.sort.to_h } + main_result = JSON.generate({b: 1, a: 2}, sort_keys: true) + + exit(results == [:rejected, '{"b":1,"a":2}'] && main_result == '{"a":2,"b":1}' ? 0 : 1) + end + _, status = Process.waitpid2(pid) + assert_predicate status, :success? + end if Ractor.respond_to?(:shareable_lambda) end if defined?(Ractor) && Process.respond_to?(:fork)