Make State#configure and #merge only write the options they are given - #1076
Merged
Merged
Conversation
The pure-Ruby generator's private _configure gives every keyword a literal default, so a call that passes one option silently resets the other fifteen. The method is also aliased as merge, so a state built with indent/object_nl starts emitting compact JSON after any later configure call. The C extension does not behave this way. configure_state_i walks only the keys actually present in the hash, so State#merge really merges there. Default the keywords to the current ivars so the pure generator agrees. initialize already assigns every ivar to its literal default before calling _configure(**opts), so construction is unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On the pure-Ruby generator (the one TruffleRuby loads),
JSON::State#configure-- and therefore its alias#merge-- resets every generation option it was not given back to that option's default.The consumer is the public
State#configure/State#mergepair itself. Anything that hands a configured state around and lets a later stage adjust one knob loses the rest:to_h's own rdoc (lib/json/ext/generator/state.rb:38-41) advertises the round trip -- "Returns the configuration instance variables as a hash, that can be passed to the configure method" -- and on the pure generatorstate.configure(state.to_h.slice(:depth))throws away everything else. The same holds formax_nesting,allow_nan,ascii_only,script_safe,strict,buffer_initial_length,sort_keysandallow_duplicate_key.The existing coverage cannot see this.
test_configure_using_configure_and_merge(test/json/json_generator_test.rb:495) callsmergeon a freshJSON.state.newand passes all five options at once, so "merge into defaults" and "replace with defaults plus these five" produce the same state.Why this is a bug and not a design choice
Your own C extension already merges.
configure_state_i(ext/json/ext/generator/generator.c:1860) is anif/else ifchain that writes onlysym_indent,sym_space,sym_space_before, ... as they appear, and it is driven byrb_hash_foreach(config, configure_state_i, (VALUE)&data)atgenerator.c:1911, right under the comment atgenerator.c:1909:So on CRuby,
State#mergereally merges. The pure_configureatlib/json/truffle_ruby/generator.rb:305gives every keyword a literal default and therefore replaces. The alias is literally namedmerge(lib/json/truffle_ruby/generator.rb:303).Where the three implementations stand today
configure_state_i)GeneratorState#_configure)indent,space,space_before,object_nl,array_nlif (x != null) this.x = x;,GeneratorState.java:548-563)max_nesting,allow_nan,ascii_only,script_safe,strict,buffer_initial_length,depth,allow_duplicate_key,sort_keys,as_jsonopts.getInt(..., DEFAULT_...)/opts.getBool(..., DEFAULT_...),GeneratorState.java:565-576)indent: nil""OptionsReader#getStringreturnsnullfor a falsy value)""""This patch only moves the pure generator onto the C extension's behaviour. The Java extension is a third case in the middle row; I did not touch it, and I have a question about it at the bottom.
Fix
Construction is byte-identical:
initialize(lib/json/truffle_ruby/generator.rb:155-171) assigns every ivar to its literal default before calling_configure(**opts) if opts, so a freshState.newstill starts from the documented defaults.sort_keys=(:212) is idempotent forProc/false/true, soself.sort_keys = @sort_keysis a no-op.The literal defaults in the old signature were the only place the pure generator disagreed with the C extension here; #664 ("Skip calling configure if there are no options") is what made
initializeseed the ivars, and it left these literals in place.Tests
Four tests, added next to
test_configure_using_configure_and_merge.The first two are unguarded on purpose: C and Java both merge the string options, so these pass on every CI row and go red only on the pure generator.
The other two touch the non-string options and an explicit
nil, which is exactly where the Java extension is a third case, so they carry the repo's own engine guard (omit ... if RUBY_ENGINE == 'jruby', as used attest/json/json_parser_test.rb:830andtest/json/resumable_parser_test.rb:9):I read
java/src/json/ext/GeneratorState.java:544-580andjava/src/json/ext/OptionsReader.javato write those two guards; without them thejruby-9.4rows in.github/workflows/ci.ymlwould go red on a patch that does not touch the Java extension.Verification
TruffleRuby is not installed on this machine, so I could not run the real engine. Instead I force-loaded the clone's pure generator on CRuby exactly the way
lib/json/ext.rb:34-36does it for TruffleRuby, and printed the loaded method'ssource_locationon every run so the swap is provable:Red / green under that harness,
-n "/test_configure_/":6 tests, 19 assertions, 4 failures-- all four new tests fail6 tests, 31 assertions, 0 failuresThe failure that matters:
No regression on the engine that actually loads this file: I ran the full
json_generator_test.rbplusjson_coder_test.rb,json_common_interface_test.rbandjson_encoding_test.rbunder the pure generator, before and after, and got no delta across the four suites. (Three failures appear in both runs; they are artifacts of the hybrid C-parser + pure-generator harness, identical before and after.)Repo gates, taken verbatim from
.github/workflows/ci.yml:55-59.bundleis broken on this machine so I ran barerake:rake compilerake test JSON_COMPACT=1589 tests, 3401 assertions, 0 failures593 tests, 3419 assertions, 0 failuresrake buildjson 3.0.2 built to pkg/json-3.0.2.gemThat is the normal CRuby run, so it exercises the C extension: the four new tests pass there both before and after the patch, with
0 omissions. That is the point -- they encode the C extension's behaviour, and only the pure generator was failing to match it.Mutation-checked in both directions, under the pure generator:
niland write@indent = indent || @indent("an explicit nil means keep") -> dies ontest_configure_writes_a_string_option_given_as_nil(<""> expected but was <"1">), because the C extension writes""there.test_configure_only_writes_the_other_options_it_is_given(<3> expected but was <100>).One question
Should
GeneratorState#_configure(java/src/json/ext/GeneratorState.java:565-576) get the same treatment? It resets the non-string options for the same reason --opts.getInt("max_nesting", DEFAULT_MAX_NESTING)supplies a default rather than the current field. I left it alone because I have no JRuby build here to red/green it, and I did not want to ship an untested change to a second extension. Happy to open a follow-up if you want it aligned.I used AI assistance for this change: Claude Code, model Claude Opus 5. I reproduced the failure, wrote and ran the tests and the gates myself, and I can explain and defend every line of the diff.