From c2cd95393de9025fe418b25773aee3a6396e90a1 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 25 Oct 2024 12:36:53 +0200 Subject: [PATCH 01/16] Cleanup test_helper.rb --- test/json/test_helper.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/json/test_helper.rb b/test/json/test_helper.rb index 4955a02c..e8bba16f 100644 --- a/test/json/test_helper.rb +++ b/test/json/test_helper.rb @@ -1,12 +1,12 @@ case ENV['JSON'] when 'pure' - $:.unshift File.join(__dir__, '../../lib') + $LOAD_PATH.unshift(File.expand_path('../../../lib', __FILE__)) require 'json/pure' when 'ext' - $:.unshift File.join(__dir__, '../../ext'), File.join(__dir__, '../../lib') + $LOAD_PATH.unshift(File.expand_path('../../../ext', __FILE__), File.expand_path('../../../lib', __FILE__)) require 'json/ext' else - $:.unshift File.join(__dir__, '../../ext'), File.join(__dir__, '../../lib') + $LOAD_PATH.unshift(File.expand_path('../../../ext', __FILE__), File.expand_path('../../../lib', __FILE__)) require 'json' end From a0cd1de12658e4d3ee671edfbfce384c70164508 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 25 Oct 2024 09:55:25 +0200 Subject: [PATCH 02/16] Workaround rubygems $LOAD_PATH bug Ref: https://github.com/ruby/json/issues/647 Ref: https://github.com/rubygems/rubygems/pull/6490 Older rubygems are executing `extconf.rb` with a broken `$LOAD_PATH` causing the `json` gem native extension to be loaded with the stdlib version of the `.rb` files. This fails with ``` json/common.rb:82:in `initialize': wrong number of arguments (given 1, expected 0) (ArgumentError) ``` Since this is just for `extconf.rb` we can probably just accept that extra argument and ignore it. The bug was fixed in rubygems 3.4.9 / 2023-03-20 --- ext/json/ext/generator/generator.c | 9 +++++++++ test/json/json_generator_test.rb | 24 ++++++++++++------------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index c35e86d9..6bc3d420 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -963,6 +963,12 @@ static VALUE cState_generate(VALUE self, VALUE obj) return result; } +static VALUE cState_initialize(int argc, VALUE *argv, VALUE self) +{ + rb_warn("The json gem extension was loaded with the stdlib ruby code. You should upgrade rubygems with `gem update --system`"); + return self; +} + /* * call-seq: initialize_copy(orig) * @@ -1408,6 +1414,9 @@ void Init_generator(void) cState = rb_define_class_under(mGenerator, "State", rb_cObject); rb_define_alloc_func(cState, cState_s_allocate); rb_define_singleton_method(cState, "from_state", cState_from_state_s, 1); + rb_define_method(cState, "initialize", cState_initialize, -1); + rb_define_alias(cState, "initialize", "initialize"); // avoid method redefinition warnings + rb_define_method(cState, "initialize_copy", cState_init_copy, 1); rb_define_method(cState, "indent", cState_indent, 0); rb_define_method(cState, "indent=", cState_indent_set, 1); diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index 7dc45e3a..53a04a35 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -261,19 +261,19 @@ def test_buffer_initial_length end def test_gc - if respond_to?(:assert_in_out_err) && !(RUBY_PLATFORM =~ /java/) - assert_in_out_err(%w[-rjson -Ilib -Iext], <<-EOS, [], []) - bignum_too_long_to_embed_as_string = 1234567890123456789012345 - expect = bignum_too_long_to_embed_as_string.to_s - GC.stress = true - - 10.times do |i| - tmp = bignum_too_long_to_embed_as_string.to_json - raise "'\#{expect}' is expected, but '\#{tmp}'" unless tmp == expect - end - EOS + pid = fork do + bignum_too_long_to_embed_as_string = 1234567890123456789012345 + expect = bignum_too_long_to_embed_as_string.to_s + GC.stress = true + + 10.times do |i| + tmp = bignum_too_long_to_embed_as_string.to_json + raise "#{expect}' is expected, but '#{tmp}'" unless tmp == expect + end end - end if GC.respond_to?(:stress=) + _, status = Process.waitpid2(pid) + assert_predicate status, :success? + end if GC.respond_to?(:stress=) && Process.respond_to?(:fork) def test_configure_using_configure_and_merge numbered_state = { From 7fd353198d590844fa093d684c584233aa4a9df0 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 25 Oct 2024 12:00:21 +0200 Subject: [PATCH 03/16] Workaround being loaded alongside a different `json_pure` version Fix: https://github.com/ruby/json/issues/646 Since both `json` and `json_pure` expose the same files, if the versions don't match, the native extension may be loaded with Ruby code that don't match and is incompatible. By doing the `require json/ext/generator/state` from C we ensure we're at least loading that. But this is a dirty workaround for the 2.7.x branch, we should find a better way to fully isolate the two gems. --- ext/json/ext/generator/generator.c | 2 ++ lib/json/ext.rb | 3 --- test/json/ractor_test.rb | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index 6bc3d420..abeffeda 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -1507,4 +1507,6 @@ void Init_generator(void) usascii_encindex = rb_usascii_encindex(); utf8_encindex = rb_utf8_encindex(); binary_encindex = rb_ascii8bit_encindex(); + + rb_require("json/ext/generator/state"); } diff --git a/lib/json/ext.rb b/lib/json/ext.rb index 775e28a9..92ef61ea 100644 --- a/lib/json/ext.rb +++ b/lib/json/ext.rb @@ -15,9 +15,6 @@ module Ext else require 'json/ext/parser' require 'json/ext/generator' - unless RUBY_ENGINE == 'jruby' - require 'json/ext/generator/state' - end $DEBUG and warn "Using Ext extension for JSON." JSON.parser = Parser JSON.generator = Generator diff --git a/test/json/ractor_test.rb b/test/json/ractor_test.rb index e0116400..646baddf 100644 --- a/test/json/ractor_test.rb +++ b/test/json/ractor_test.rb @@ -9,7 +9,7 @@ class JSONInRactorTest < Test::Unit::TestCase def test_generate - assert_separately([], "#{<<~"begin;"}\n#{<<~'end;'}", ignore_stderr: true) + assert_separately(%w[-rjson -Ilib -Iext], "#{<<~"begin;"}\n#{<<~'end;'}", ignore_stderr: true) begin; $VERBOSE = nil require "json" From e285489d55dce04fc354ea12da401879239692cd Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 25 Oct 2024 12:35:29 +0200 Subject: [PATCH 04/16] json_pure: fix ractor compatibility This actually never worked, because the test was always testing the ext version from the stdlib, never the pure version nor the current ext version. --- lib/json/pure/parser.rb | 29 +++++++++++++++-------------- test/json/ractor_test.rb | 28 +++++++++++++++++++--------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/lib/json/pure/parser.rb b/lib/json/pure/parser.rb index 3dafe830..b5c2b1ba 100644 --- a/lib/json/pure/parser.rb +++ b/lib/json/pure/parser.rb @@ -148,25 +148,26 @@ def convert_encoding(source) end # Unescape characters in strings. - UNESCAPE_MAP = Hash.new { |h, k| h[k] = k.chr } - UNESCAPE_MAP.update({ - ?" => '"', - ?\\ => '\\', - ?/ => '/', - ?b => "\b", - ?f => "\f", - ?n => "\n", - ?r => "\r", - ?t => "\t", - ?u => nil, - }) + # UNESCAPE_MAP = Hash.new { |h, k| puts; p [:k, k]; h[k] = k.chr } + UNESCAPE_MAP = { + '"' => '"', + '\\' => '\\', + '/' => '/', + 'b' => "\b", + 'f' => "\f", + 'n' => "\n", + 'r' => "\r", + 't' => "\t", + 'u' => nil, + }.freeze STR_UMINUS = ''.respond_to?(:-@) def parse_string if scan(STRING) return '' if self[1].empty? - string = self[1].gsub(%r((?:\\[\\bfnrt"/]|(?:\\u(?:[A-Fa-f\d]{4}))+|\\[\x20-\xff]))n) do |c| - if u = UNESCAPE_MAP[$&[1]] + string = self[1].gsub(%r{(?:\\[\\bfnrt"/]|(?:\\u(?:[A-Fa-f\d]{4}))+|\\[\x20-\xff])}n) do |c| + k = $&[1] + if u = UNESCAPE_MAP.fetch(k) { k.chr } u else # \uXXXX bytes = ''.b diff --git a/test/json/ractor_test.rb b/test/json/ractor_test.rb index 646baddf..f857c9a8 100644 --- a/test/json/ractor_test.rb +++ b/test/json/ractor_test.rb @@ -9,10 +9,7 @@ class JSONInRactorTest < Test::Unit::TestCase def test_generate - assert_separately(%w[-rjson -Ilib -Iext], "#{<<~"begin;"}\n#{<<~'end;'}", ignore_stderr: true) - begin; - $VERBOSE = nil - require "json" + pid = fork do r = Ractor.new do json = JSON.generate({ 'a' => 2, @@ -26,9 +23,22 @@ def test_generate }) JSON.parse(json) end - expected_json = '{"a":2,"b":3.141,"c":"c","d":[1,"b",3.14],"e":{"foo":"bar"},' + - '"g":"\\"\\u0000\\u001f","h":1000.0,"i":0.001}' - assert_equal(JSON.parse(expected_json), r.take) - end; + expected_json = JSON.parse('{"a":2,"b":3.141,"c":"c","d":[1,"b",3.14],"e":{"foo":"bar"},' + + '"g":"\\"\\u0000\\u001f","h":1000.0,"i":0.001}') + actual_json = r.take + + if expected_json == actual_json + exit 0 + else + puts "Expected:" + puts expected_json + puts "Acutual:" + puts actual_json + puts + exit 1 + end + end + _, status = Process.waitpid2(pid) + assert_predicate status, :success? end -end if defined?(Ractor) +end if defined?(Ractor) && Process.respond_to?(:fork) From 10981db1a462acf2f2dc3271f2ec9788b0734e1e Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 25 Oct 2024 12:46:50 +0200 Subject: [PATCH 05/16] Update CHANGES --- CHANGES.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 665635bb..2893b2b1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,9 @@ # Changes +* Workaround a bug in 3.4.8 and older https://github.com/rubygems/rubygems/pull/6490. +* Workaround different versions of `json` and `json_pure` being loaded (not officially supported). +* Make `json_pure` Ractor compatible. + ### 2024-10-24 (2.7.3) * Numerous performance optimizations in `JSON.generate` and `JSON.dump` (up to 2 times faster). From 31a910061051a8384d0e85bbf45d3a26941130c2 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 25 Oct 2024 12:55:49 +0200 Subject: [PATCH 06/16] Release 2.7.4 --- CHANGES.md | 3 +++ lib/json/pure/parser.rb | 1 - lib/json/version.rb | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 2893b2b1..0349d126 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,9 @@ # Changes +### 2024-10-25 (2.7.4) + * Workaround a bug in 3.4.8 and older https://github.com/rubygems/rubygems/pull/6490. + This bug would cause some gems with native extension to fail during compilation. * Workaround different versions of `json` and `json_pure` being loaded (not officially supported). * Make `json_pure` Ractor compatible. diff --git a/lib/json/pure/parser.rb b/lib/json/pure/parser.rb index b5c2b1ba..82099e68 100644 --- a/lib/json/pure/parser.rb +++ b/lib/json/pure/parser.rb @@ -148,7 +148,6 @@ def convert_encoding(source) end # Unescape characters in strings. - # UNESCAPE_MAP = Hash.new { |h, k| puts; p [:k, k]; h[k] = k.chr } UNESCAPE_MAP = { '"' => '"', '\\' => '\\', diff --git a/lib/json/version.rb b/lib/json/version.rb index d3d621b7..e8ec3efd 100644 --- a/lib/json/version.rb +++ b/lib/json/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module JSON - VERSION = '2.7.3' + VERSION = '2.7.4' end From 4af0ea55ca2233871b687bdc9a4ffbfffea1263d Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Fri, 25 Oct 2024 19:20:00 +0200 Subject: [PATCH 07/16] Instantiate Parser with a kwsplat Prior to 2.7.3, `JSON::Ext::Parser` would only take kwargs. So if json_pure 2.7.4 is loaded with `json <= 2.7.2` (or stdlib) it blows up. Ref: https://github.com/ruby/json/issues/650 Fix: https://github.com/ruby/json/issues/651 --- CHANGES.md | 2 ++ lib/json/common.rb | 7 ++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 0349d126..4eacb238 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,7 @@ # Changes +* Workaround another issue caused by conflicting versions of both `json_pure` and `json` being loaded. + ### 2024-10-25 (2.7.4) * Workaround a bug in 3.4.8 and older https://github.com/rubygems/rubygems/pull/6490. diff --git a/lib/json/common.rb b/lib/json/common.rb index bb37820a..d79c5024 100644 --- a/lib/json/common.rb +++ b/lib/json/common.rb @@ -219,7 +219,12 @@ def parse(source, opts = nil) if opts.nil? Parser.new(source).parse else - Parser.new(source, opts).parse + # NB: The ** shouldn't be required, but we have to deal with + # different versions of the `json` and `json_pure` gems being + # loaded concurrently. + # Prior to 2.7.3, `JSON::Ext::Parser` would only take kwargs. + # Ref: https://github.com/ruby/json/issues/650 + Parser.new(source, **opts).parse end end From c3189289b129644f4aa2a221085ec6e04ca660b6 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Mon, 28 Oct 2024 08:34:43 +0100 Subject: [PATCH 08/16] Handle all formatting configs potentially being `nil`. Fix: https://github.com/ruby/json/issues/653 I don't think this was really fully supported in the past, but it kinda worked with some of the implementations. --- CHANGES.md | 1 + lib/json/ext/generator/state.rb | 10 +++++----- lib/json/pure/generator.rb | 14 +++++++------- test/json/json_generator_test.rb | 21 +++++++++++++++++++++ 4 files changed, 34 insertions(+), 12 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 4eacb238..6d361997 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,6 @@ # Changes +* Gracefully handle formatting configs being set to `nil` instead of `""`. * Workaround another issue caused by conflicting versions of both `json_pure` and `json` being loaded. ### 2024-10-25 (2.7.4) diff --git a/lib/json/ext/generator/state.rb b/lib/json/ext/generator/state.rb index 4f9675d7..29688142 100644 --- a/lib/json/ext/generator/state.rb +++ b/lib/json/ext/generator/state.rb @@ -46,15 +46,15 @@ def configure(opts) opts.each do |key, value| case key when :indent - self.indent = value + self.indent = value || '' when :space - self.space = value + self.space = value || '' when :space_before - self.space_before = value + self.space_before = value || '' when :array_nl - self.array_nl = value + self.array_nl = value || '' when :object_nl - self.object_nl = value + self.object_nl = value || '' when :max_nesting self.max_nesting = value || 0 when :depth diff --git a/lib/json/pure/generator.rb b/lib/json/pure/generator.rb index c2268cc3..516ee287 100644 --- a/lib/json/pure/generator.rb +++ b/lib/json/pure/generator.rb @@ -239,13 +239,13 @@ def configure(opts) end # NOTE: If adding new instance variables here, check whether #generate should check them for #generate_json - @indent = opts[:indent] if opts.key?(:indent) - @space = opts[:space] if opts.key?(:space) - @space_before = opts[:space_before] if opts.key?(:space_before) - @object_nl = opts[:object_nl] if opts.key?(:object_nl) - @array_nl = opts[:array_nl] if opts.key?(:array_nl) - @allow_nan = !!opts[:allow_nan] if opts.key?(:allow_nan) - @ascii_only = opts[:ascii_only] if opts.key?(:ascii_only) + @indent = opts[:indent] || '' if opts.key?(:indent) + @space = opts[:space] || '' if opts.key?(:space) + @space_before = opts[:space_before] || '' if opts.key?(:space_before) + @object_nl = opts[:object_nl] || '' if opts.key?(:object_nl) + @array_nl = opts[:array_nl] || '' if opts.key?(:array_nl) + @allow_nan = !!opts[:allow_nan] if opts.key?(:allow_nan) + @ascii_only = opts[:ascii_only] if opts.key?(:ascii_only) @depth = opts[:depth] || 0 @buffer_initial_length ||= opts[:buffer_initial_length] diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index 53a04a35..a83d730d 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -167,6 +167,27 @@ def test_states assert s[:check_circular?] end + def test_falsy_state + object = { foo: [1, 2], bar: { egg: :spam }} + expected_json = JSON.generate( + object, + array_nl: "", + indent: "", + object_nl: "", + space: "", + space_before: "", + ) + + assert_equal expected_json, JSON.generate( + object, + array_nl: nil, + indent: nil, + object_nl: nil, + space: nil, + space_before: nil, + ) + end + def test_pretty_state state = JSON.create_pretty_state assert_equal({ From 84c9aaa056e99e23897abc7f30e36e75301d5eac Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 29 Oct 2024 18:55:22 +0100 Subject: [PATCH 09/16] Fix a memory leak in #to_json methods Fix: https://github.com/ruby/json/issues/460 The various `to_json` methods must rescue exceptions to free the buffer. ``` require 'json' data = 10_000.times.to_a << BasicObject.new 20.times do 100.times do begin data.to_json rescue NoMethodError end end puts `ps -o rss= -p #{$$}` end ``` ``` 20128 24992 29920 34672 39600 44336 49136 53936 58816 63616 68416 73232 78032 82896 87696 92528 97408 102208 107008 111808 ``` --- CHANGES.md | 1 + ext/json/ext/generator/generator.c | 50 +++++++++++++++++++++--------- ext/json/ext/generator/generator.h | 13 +------- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 6d361997..6b16a5e5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,6 @@ # Changes +* Fix a memory leak when `#to_json` methods raise an exception. * Gracefully handle formatting configs being set to `nil` instead of `""`. * Workaround another issue caused by conflicting versions of both `json_pure` and `json` being loaded. diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index abeffeda..ca48e0f1 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -403,7 +403,9 @@ static char *fstrndup(const char *ptr, unsigned long len) { */ static VALUE mHash_to_json(int argc, VALUE *argv, VALUE self) { - GENERATE_JSON(object); + rb_check_arity(argc, 0, 1); + VALUE Vstate = cState_from_state_s(cState, argc == 1 ? argv[0] : Qnil); + return cState_partial_generate(Vstate, self, generate_json_object); } /* @@ -415,7 +417,9 @@ static VALUE mHash_to_json(int argc, VALUE *argv, VALUE self) * produced JSON string output further. */ static VALUE mArray_to_json(int argc, VALUE *argv, VALUE self) { - GENERATE_JSON(array); + rb_check_arity(argc, 0, 1); + VALUE Vstate = cState_from_state_s(cState, argc == 1 ? argv[0] : Qnil); + return cState_partial_generate(Vstate, self, generate_json_array); } #ifdef RUBY_INTEGER_UNIFICATION @@ -426,7 +430,9 @@ static VALUE mArray_to_json(int argc, VALUE *argv, VALUE self) { */ static VALUE mInteger_to_json(int argc, VALUE *argv, VALUE self) { - GENERATE_JSON(integer); + rb_check_arity(argc, 0, 1); + VALUE Vstate = cState_from_state_s(cState, argc == 1 ? argv[0] : Qnil); + return cState_partial_generate(Vstate, self, generate_json_integer); } #else @@ -437,7 +443,9 @@ static VALUE mInteger_to_json(int argc, VALUE *argv, VALUE self) */ static VALUE mFixnum_to_json(int argc, VALUE *argv, VALUE self) { - GENERATE_JSON(fixnum); + rb_check_arity(argc, 0, 1); + VALUE Vstate = cState_from_state_s(cState, argc == 1 ? argv[0] : Qnil); + return cState_partial_generate(Vstate, self, generate_json_fixnum); } /* @@ -447,7 +455,9 @@ static VALUE mFixnum_to_json(int argc, VALUE *argv, VALUE self) */ static VALUE mBignum_to_json(int argc, VALUE *argv, VALUE self) { - GENERATE_JSON(bignum); + rb_check_arity(argc, 0, 1); + VALUE Vstate = cState_from_state_s(cState, argc == 1 ? argv[0] : Qnil); + return cState_partial_generate(Vstate, self, generate_json_bignum); } #endif @@ -458,7 +468,9 @@ static VALUE mBignum_to_json(int argc, VALUE *argv, VALUE self) */ static VALUE mFloat_to_json(int argc, VALUE *argv, VALUE self) { - GENERATE_JSON(float); + rb_check_arity(argc, 0, 1); + VALUE Vstate = cState_from_state_s(cState, argc == 1 ? argv[0] : Qnil); + return cState_partial_generate(Vstate, self, generate_json_float); } /* @@ -481,7 +493,9 @@ static VALUE mString_included_s(VALUE self, VALUE modul) { */ static VALUE mString_to_json(int argc, VALUE *argv, VALUE self) { - GENERATE_JSON(string); + rb_check_arity(argc, 0, 1); + VALUE Vstate = cState_from_state_s(cState, argc == 1 ? argv[0] : Qnil); + return cState_partial_generate(Vstate, self, generate_json_string); } /* @@ -536,7 +550,8 @@ static VALUE mString_Extend_json_create(VALUE self, VALUE o) */ static VALUE mTrueClass_to_json(int argc, VALUE *argv, VALUE self) { - GENERATE_JSON(true); + rb_check_arity(argc, 0, 1); + return rb_utf8_str_new("true", 4); } /* @@ -546,7 +561,8 @@ static VALUE mTrueClass_to_json(int argc, VALUE *argv, VALUE self) */ static VALUE mFalseClass_to_json(int argc, VALUE *argv, VALUE self) { - GENERATE_JSON(false); + rb_check_arity(argc, 0, 1); + return rb_utf8_str_new("false", 5); } /* @@ -556,7 +572,8 @@ static VALUE mFalseClass_to_json(int argc, VALUE *argv, VALUE self) */ static VALUE mNilClass_to_json(int argc, VALUE *argv, VALUE self) { - GENERATE_JSON(null); + rb_check_arity(argc, 0, 1); + return rb_utf8_str_new("null", 4); } /* @@ -573,7 +590,7 @@ static VALUE mObject_to_json(int argc, VALUE *argv, VALUE self) rb_scan_args(argc, argv, "01", &state); Check_Type(string, T_STRING); state = cState_from_state_s(cState, state); - return cState_partial_generate(state, string); + return cState_partial_generate(state, string, generate_json_string); } static void State_free(void *ptr) @@ -826,6 +843,7 @@ static void generate_json_integer(FBuffer *buffer, VALUE Vstate, JSON_Generator_ generate_json_bignum(buffer, Vstate, state, obj); } #endif + static void generate_json_float(FBuffer *buffer, VALUE Vstate, JSON_Generator_State *state, VALUE obj) { double value = RFLOAT_VALUE(obj); @@ -911,13 +929,14 @@ struct generate_json_data { VALUE vstate; JSON_Generator_State *state; VALUE obj; + void (*func)(FBuffer *buffer, VALUE Vstate, JSON_Generator_State *state, VALUE obj); }; static VALUE generate_json_try(VALUE d) { struct generate_json_data *data = (struct generate_json_data *)d; - generate_json(data->buffer, data->vstate, data->state, data->obj); + data->func(data->buffer, data->vstate, data->state, data->obj); return Qnil; } @@ -932,7 +951,7 @@ static VALUE generate_json_rescue(VALUE d, VALUE exc) return Qundef; } -static VALUE cState_partial_generate(VALUE self, VALUE obj) +static VALUE cState_partial_generate(VALUE self, VALUE obj, void (*func)(FBuffer *buffer, VALUE Vstate, JSON_Generator_State *state, VALUE obj)) { FBuffer *buffer = cState_prepare_buffer(self); GET_STATE(self); @@ -941,7 +960,8 @@ static VALUE cState_partial_generate(VALUE self, VALUE obj) .buffer = buffer, .vstate = self, .state = state, - .obj = obj + .obj = obj, + .func = func }; rb_rescue(generate_json_try, (VALUE)&data, generate_json_rescue, (VALUE)&data); @@ -957,7 +977,7 @@ static VALUE cState_partial_generate(VALUE self, VALUE obj) */ static VALUE cState_generate(VALUE self, VALUE obj) { - VALUE result = cState_partial_generate(self, obj); + VALUE result = cState_partial_generate(self, obj, generate_json); GET_STATE(self); (void)state; return result; diff --git a/ext/json/ext/generator/generator.h b/ext/json/ext/generator/generator.h index 3710ce7c..552fb8d0 100644 --- a/ext/json/ext/generator/generator.h +++ b/ext/json/ext/generator/generator.h @@ -54,17 +54,6 @@ typedef struct JSON_Generator_StateStruct { JSON_Generator_State *state; \ GET_STATE_TO(self, state) -#define GENERATE_JSON(type) \ - FBuffer *buffer; \ - VALUE Vstate; \ - JSON_Generator_State *state; \ - \ - rb_scan_args(argc, argv, "01", &Vstate); \ - Vstate = cState_from_state_s(cState, Vstate); \ - TypedData_Get_Struct(Vstate, JSON_Generator_State, &JSON_Generator_State_type, state); \ - buffer = cState_prepare_buffer(Vstate); \ - generate_json_##type(buffer, Vstate, state, self); \ - return fbuffer_to_s(buffer) static VALUE mHash_to_json(int argc, VALUE *argv, VALUE self); static VALUE mArray_to_json(int argc, VALUE *argv, VALUE self); @@ -99,7 +88,7 @@ static void generate_json_integer(FBuffer *buffer, VALUE Vstate, JSON_Generator_ static void generate_json_fixnum(FBuffer *buffer, VALUE Vstate, JSON_Generator_State *state, VALUE obj); static void generate_json_bignum(FBuffer *buffer, VALUE Vstate, JSON_Generator_State *state, VALUE obj); static void generate_json_float(FBuffer *buffer, VALUE Vstate, JSON_Generator_State *state, VALUE obj); -static VALUE cState_partial_generate(VALUE self, VALUE obj); +static VALUE cState_partial_generate(VALUE self, VALUE obj, void (*func)(FBuffer *buffer, VALUE Vstate, JSON_Generator_State *state, VALUE obj)); static VALUE cState_generate(VALUE self, VALUE obj); static VALUE cState_from_state_s(VALUE self, VALUE opts); static VALUE cState_indent(VALUE self); From 15afc68dc5ea0489c892bb86f9e279d9d5e2e004 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Wed, 30 Oct 2024 12:14:08 +0100 Subject: [PATCH 10/16] Release 2.7.5 --- CHANGES.md | 2 ++ lib/json/version.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 6b16a5e5..667958ea 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,7 @@ # Changes +### 2024-10-25 (2.7.5) + * Fix a memory leak when `#to_json` methods raise an exception. * Gracefully handle formatting configs being set to `nil` instead of `""`. * Workaround another issue caused by conflicting versions of both `json_pure` and `json` being loaded. diff --git a/lib/json/version.rb b/lib/json/version.rb index e8ec3efd..92d6e002 100644 --- a/lib/json/version.rb +++ b/lib/json/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module JSON - VERSION = '2.7.4' + VERSION = '2.7.5' end From 045e58d8310890c43bf48a3b9ed6062b781b52c2 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Thu, 31 Oct 2024 08:52:19 +0100 Subject: [PATCH 11/16] JSON.generate: call to_json on String subclasses Fix: https://github.com/ruby/json/issues/667 This is yet another behavior on which the various implementations differed, but the C implementation used to call `to_json` on String subclasses used as keys. This was optimized out in e125072130229e54a651f7b11d7d5a782ae7fb65 but there is an Active Support test case for it, so it's best to make all 3 implementation respect this behavior. --- CHANGES.md | 2 ++ ext/json/ext/fbuffer/fbuffer.h | 4 +++ ext/json/ext/generator/generator.c | 6 +++- java/src/json/ext/Generator.java | 9 +++++- lib/json/pure/generator.rb | 33 +++++++++++++++----- test/json/json_generator_test.rb | 50 ++++++++++++++++++++++++++++++ 6 files changed, 95 insertions(+), 9 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 667958ea..1cab01ee 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,7 @@ # Changes +* Fix a regression in JSON.generate when dealing with Hash keys that are string subclasses, call `to_json` on them. + ### 2024-10-25 (2.7.5) * Fix a memory leak when `#to_json` methods raise an exception. diff --git a/ext/json/ext/fbuffer/fbuffer.h b/ext/json/ext/fbuffer/fbuffer.h index 76bd6ce1..4c62aa36 100644 --- a/ext/json/ext/fbuffer/fbuffer.h +++ b/ext/json/ext/fbuffer/fbuffer.h @@ -36,6 +36,10 @@ static VALUE fbuffer_to_s(FBuffer *fb); #define RB_UNLIKELY(expr) expr #endif +#ifndef RB_LIKELY +#define RB_LIKELY(expr) expr +#endif + static FBuffer *fbuffer_alloc(unsigned long initial_length) { FBuffer *fb; diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index ca48e0f1..ca9f1a9c 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -678,7 +678,11 @@ json_object_i(VALUE key, VALUE val, VALUE _arg) break; } - generate_json_string(buffer, Vstate, state, key_to_s); + if (RB_LIKELY(RBASIC_CLASS(key_to_s) == rb_cString)) { + generate_json_string(buffer, Vstate, state, key_to_s); + } else { + generate_json(buffer, Vstate, state, key_to_s); + } if (RB_UNLIKELY(state->space_before)) fbuffer_append(buffer, state->space_before, state->space_before_len); fbuffer_append_char(buffer, ':'); if (RB_UNLIKELY(state->space)) fbuffer_append(buffer, state->space, state->space_len); diff --git a/java/src/json/ext/Generator.java b/java/src/json/ext/Generator.java index 5a296c8f..d818ce49 100644 --- a/java/src/json/ext/Generator.java +++ b/java/src/json/ext/Generator.java @@ -347,7 +347,14 @@ public void visit(IRubyObject key, IRubyObject value) { } if (objectNl.length() != 0) buffer.append(indent); - STRING_HANDLER.generate(session, key.asString(), buffer); + IRubyObject keyStr = key.callMethod(context, "to_s"); + if (keyStr.getMetaClass() == runtime.getString()) { + STRING_HANDLER.generate(session, (RubyString)keyStr, buffer); + } else { + Utils.ensureString(keyStr); + Handler keyHandler = (Handler) getHandlerFor(runtime, keyStr); + keyHandler.generate(session, keyStr, buffer); + } session.infectBy(key); buffer.append(spaceBefore); diff --git a/lib/json/pure/generator.rb b/lib/json/pure/generator.rb index 516ee287..0a563813 100644 --- a/lib/json/pure/generator.rb +++ b/lib/json/pure/generator.rb @@ -301,19 +301,30 @@ def generate(obj) # Handles @allow_nan, @buffer_initial_length, other ivars must be the default value (see above) private def generate_json(obj, buf) - case obj - when Hash + klass = obj.class + if klass == Hash buf << '{' first = true obj.each_pair do |k,v| buf << ',' unless first - fast_serialize_string(k.to_s, buf) + + key_str = k.to_s + if key_str.is_a?(::String) + if key_str.class == ::String + fast_serialize_string(key_str, buf) + else + generate_json(key_str, buf) + end + else + raise TypeError, "#{k.class}#to_s returns an instance of #{key_str.class}, expected a String" + end + buf << ':' generate_json(v, buf) first = false end buf << '}' - when Array + elsif klass == Array buf << '[' first = true obj.each do |e| @@ -322,9 +333,9 @@ def generate(obj) first = false end buf << ']' - when String + elsif klass == String fast_serialize_string(obj, buf) - when Integer + elsif klass == Integer buf << obj.to_s else # Note: Float is handled this way since Float#to_s is slow anyway @@ -414,7 +425,15 @@ def json_transform(state) each { |key, value| result << delim unless first result << state.indent * depth if indent - result = +"#{result}#{key.to_s.to_json(state)}#{state.space_before}:#{state.space}" + + key_str = key.to_s + key_json = if key_str.is_a?(::String) + key_str = key_str.to_json(state) + else + raise TypeError, "#{key.class}#to_s returns an instance of #{key_str.class}, expected a String" + end + + result = +"#{result}#{key_json}#{state.space_before}:#{state.space}" if state.strict? && !(false == value || true == value || nil == value || String === value || Array === value || Hash === value || Integer === value || Float === value) raise GeneratorError, "#{value.class} not allowed in JSON" elsif value.respond_to?(:to_json) diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index a83d730d..8e76bec3 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -479,6 +479,56 @@ def test_invalid_encoding_string end end + class MyCustomString < String + def to_json(_state = nil) + '"my_custom_key"' + end + + def to_s + self + end + end + + def test_string_subclass_as_keys + # Ref: https://github.com/ruby/json/issues/667 + # if key.to_s doesn't return a bare string, we call `to_json` on it. + key = MyCustomString.new("won't be used") + assert_equal '{"my_custom_key":1}', JSON.generate(key => 1) + end + + class FakeString + def to_json(_state = nil) + raise "Shouldn't be called" + end + + def to_s + self + end + end + + def test_custom_object_as_keys + key = FakeString.new + error = assert_raise(TypeError) do + JSON.generate(key => 1) + end + assert_match "FakeString", error.message + end + + def test_to_json_called_with_state_object + object = Object.new + called = false + argument = nil + object.singleton_class.define_method(:to_json) do |state| + called = true + argument = state + "" + end + + assert_equal "", JSON.dump(object) + assert called, "#to_json wasn't called" + assert_instance_of JSON::State, argument + end + if defined?(JSON::Ext::Generator) and RUBY_PLATFORM != "java" def test_valid_utf8_in_different_encoding utf8_string = "€™" From a0d65915c0a71de6461f3031ed0e087717aec1c0 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 3 Nov 2024 23:21:46 +0100 Subject: [PATCH 12/16] Fix test suite on Ruby 2.4 and older --- test/json/json_generator_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index 8e76bec3..29eebae6 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -518,7 +518,7 @@ def test_to_json_called_with_state_object object = Object.new called = false argument = nil - object.singleton_class.define_method(:to_json) do |state| + object.singleton_class.send(:define_method, :to_json) do |state| called = true argument = state "" From e08d22238b9ddbe4ec29530896740e0d555ccf18 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Sun, 3 Nov 2024 23:25:50 +0100 Subject: [PATCH 13/16] Fix test suite on JRuby --- java/src/json/ext/GeneratorState.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/java/src/json/ext/GeneratorState.java b/java/src/json/ext/GeneratorState.java index 909f1a56..04ff5556 100644 --- a/java/src/json/ext/GeneratorState.java +++ b/java/src/json/ext/GeneratorState.java @@ -225,7 +225,10 @@ public IRubyObject initialize_copy(ThreadContext context, IRubyObject vOrig) { public IRubyObject generate(ThreadContext context, IRubyObject obj) { RubyString result = Generator.generateJson(context, obj, this); RuntimeInfo info = RuntimeInfo.forRuntime(context.getRuntime()); - result.force_encoding(context, info.utf8.get()); + if (result.encoding(context) != info.utf8.get()) { + result = (RubyString)result.dup(); + result.force_encoding(context, info.utf8.get()); + } return result; } From 987f59d006242ab38f5f0220b3ec8add9f75ae9a Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Mon, 4 Nov 2024 14:14:12 +0100 Subject: [PATCH 14/16] Merge pull request #679 from casperisfine/generator-custom-base-types Add tests for the behavior of JSON.generate with base types subclasses --- ext/json/ext/generator/generator.c | 6 ++- test/json/json_generator_test.rb | 66 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index ca9f1a9c..09fdd3a1 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -668,7 +668,11 @@ json_object_i(VALUE key, VALUE val, VALUE _arg) VALUE key_to_s; switch(rb_type(key)) { case T_STRING: - key_to_s = key; + if (RB_LIKELY(RBASIC_CLASS(key) == rb_cString)) { + key_to_s = key; + } else { + key_to_s = rb_funcall(key, i_to_s, 0); + } break; case T_SYMBOL: key_to_s = rb_sym2str(key); diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index 29eebae6..f30f9cf4 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -529,6 +529,72 @@ def test_to_json_called_with_state_object assert_instance_of JSON::State, argument end + module CustomToJSON + def to_json(*) + %{"#{self.class.name}#to_json"} + end + end + + module CustomToS + def to_s + "#{self.class.name}#to_s" + end + end + + class ArrayWithToJSON < Array + include CustomToJSON + end + + def test_array_subclass_with_to_json + assert_equal '["JSONGeneratorTest::ArrayWithToJSON#to_json"]', JSON.generate([ArrayWithToJSON.new]) + assert_equal '{"[]":1}', JSON.generate(ArrayWithToJSON.new => 1) + end + + class ArrayWithToS < Array + include CustomToS + end + + def test_array_subclass_with_to_s + assert_equal '[[]]', JSON.generate([ArrayWithToS.new]) + assert_equal '{"JSONGeneratorTest::ArrayWithToS#to_s":1}', JSON.generate(ArrayWithToS.new => 1) + end + + class HashWithToJSON < Hash + include CustomToJSON + end + + def test_hash_subclass_with_to_json + assert_equal '["JSONGeneratorTest::HashWithToJSON#to_json"]', JSON.generate([HashWithToJSON.new]) + assert_equal '{"{}":1}', JSON.generate(HashWithToJSON.new => 1) + end + + class HashWithToS < Hash + include CustomToS + end + + def test_hash_subclass_with_to_s + assert_equal '[{}]', JSON.generate([HashWithToS.new]) + assert_equal '{"JSONGeneratorTest::HashWithToS#to_s":1}', JSON.generate(HashWithToS.new => 1) + end + + class StringWithToJSON < String + include CustomToJSON + end + + def test_string_subclass_with_to_json + assert_equal '["JSONGeneratorTest::StringWithToJSON#to_json"]', JSON.generate([StringWithToJSON.new]) + assert_equal '{"":1}', JSON.generate(StringWithToJSON.new => 1) + end + + class StringWithToS < String + include CustomToS + end + + def test_string_subclass_with_to_s + assert_equal '[""]', JSON.generate([StringWithToS.new]) + assert_equal '{"JSONGeneratorTest::StringWithToS#to_s":1}', JSON.generate(StringWithToS.new => 1) + end + if defined?(JSON::Ext::Generator) and RUBY_PLATFORM != "java" def test_valid_utf8_in_different_encoding utf8_string = "€™" From e3a36954eb2cdca6020938ff0b6d8067ab30546b Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Mon, 4 Nov 2024 14:16:11 +0100 Subject: [PATCH 15/16] Release 2.7.6 --- CHANGES.md | 2 ++ lib/json/version.rb | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index 1cab01ee..9466fb5e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,7 @@ # Changes +### 2024-11-04 (2.7.6) + * Fix a regression in JSON.generate when dealing with Hash keys that are string subclasses, call `to_json` on them. ### 2024-10-25 (2.7.5) diff --git a/lib/json/version.rb b/lib/json/version.rb index 92d6e002..382faf8b 100644 --- a/lib/json/version.rb +++ b/lib/json/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module JSON - VERSION = '2.7.5' + VERSION = '2.7.6' end From 2f7614744b7d027688b371dbff650270e56a88c9 Mon Sep 17 00:00:00 2001 From: Jean Boussier Date: Tue, 5 Nov 2024 14:13:42 +0100 Subject: [PATCH 16/16] Raise JSON::GeneratorError instead of Encoding::UndefinedConversionError Followup: https://github.com/ruby/json/pull/633 That's what was raised historically. You could argue that this new exception is more precise, but I've encountered some real production code that expected the old behavior and that was broken by this change. --- ext/json/ext/generator/generator.c | 4 ++++ java/src/json/ext/Generator.java | 20 +++++++++++++------- lib/json/pure/generator.rb | 12 ++++++++++-- test/json/json_generator_test.rb | 14 +++++++++++--- 4 files changed, 38 insertions(+), 12 deletions(-) diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index 09fdd3a1..a04df36d 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -954,6 +954,10 @@ static VALUE generate_json_rescue(VALUE d, VALUE exc) struct generate_json_data *data = (struct generate_json_data *)d; fbuffer_free(data->buffer); + if (RBASIC_CLASS(exc) == rb_path2class("Encoding::UndefinedConversionError")) { + exc = rb_exc_new_str(eGeneratorError, rb_funcall(exc, rb_intern("message"), 0)); + } + rb_exc_raise(exc); return Qundef; diff --git a/java/src/json/ext/Generator.java b/java/src/json/ext/Generator.java index d818ce49..3c8247b9 100644 --- a/java/src/json/ext/Generator.java +++ b/java/src/json/ext/Generator.java @@ -17,6 +17,7 @@ import org.jruby.runtime.ThreadContext; import org.jruby.runtime.builtin.IRubyObject; import org.jruby.util.ByteList; +import org.jruby.exceptions.RaiseException; public final class Generator { private Generator() { @@ -390,14 +391,19 @@ void generate(Session session, RubyString object, ByteList buffer) { RuntimeInfo info = session.getInfo(); RubyString src; - if (object.encoding(session.getContext()) != info.utf8.get()) { - src = (RubyString)object.encode(session.getContext(), - info.utf8.get()); - } else { - src = object; - } + try { + if (object.encoding(session.getContext()) != info.utf8.get()) { + src = (RubyString)object.encode(session.getContext(), + info.utf8.get()); + } else { + src = object; + } - session.getStringEncoder().encode(src.getByteList(), buffer); + session.getStringEncoder().encode(src.getByteList(), buffer); + } catch (RaiseException re) { + throw Utils.newException(session.getContext(), Utils.M_GENERATOR_ERROR, + re.getMessage()); + } } }; diff --git a/lib/json/pure/generator.rb b/lib/json/pure/generator.rb index 0a563813..0a5c7856 100644 --- a/lib/json/pure/generator.rb +++ b/lib/json/pure/generator.rb @@ -347,7 +347,11 @@ def generate(obj) if Regexp.method_defined?(:match?) private def fast_serialize_string(string, buf) # :nodoc: buf << '"' - string = string.encode(::Encoding::UTF_8) unless string.encoding == ::Encoding::UTF_8 + begin + string = string.encode(::Encoding::UTF_8) unless string.encoding == ::Encoding::UTF_8 + rescue Encoding::UndefinedConversionError => error + raise GeneratorError, error.message + end raise GeneratorError, "source sequence is illegal/malformed utf-8" unless string.valid_encoding? if /["\\\x0-\x1f]/n.match?(string) @@ -536,7 +540,11 @@ def to_json(state = nil, *args) end string = self else - string = encode(::Encoding::UTF_8) + begin + string = encode(::Encoding::UTF_8) + rescue Encoding::UndefinedConversionError => error + raise GeneratorError, error.message + end end if state.ascii_only? %("#{JSON.utf8_to_json_ascii(string, state.script_safe)}") diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index f30f9cf4..c829e44c 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -470,12 +470,20 @@ def test_invalid_encoding_string end assert_includes error.message, "source sequence is illegal/malformed utf-8" - assert_raise(Encoding::UndefinedConversionError) do + assert_raise(JSON::GeneratorError) do + JSON.dump("\x82\xAC\xEF".b) + end + + assert_raise(JSON::GeneratorError) do "\x82\xAC\xEF".b.to_json end - assert_raise(Encoding::UndefinedConversionError) do - JSON.dump("\x82\xAC\xEF".b) + assert_raise(JSON::GeneratorError) do + ["\x82\xAC\xEF".b].to_json + end + + assert_raise(JSON::GeneratorError) do + { foo: "\x82\xAC\xEF".b }.to_json end end