Skip to content

Commit 6e91fdf

Browse files
redconfettibyroot
authored andcommitted
JRuby: make JSON::Coder thread-safe (#1064)
1 parent 4d25455 commit 6e91fdf

4 files changed

Lines changed: 88 additions & 4 deletions

File tree

‎CHANGES.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Fix `JSON.generate` not detecting duplicate keys such as `{ "1" => 1, 1 => 2 }`.
77
* JRuby: raise `TypeError` when a String subclass used as a Hash key has a `to_s` that doesn't return a String, like the C extension.
88
* JRuby and TruffleRuby: fix a stack overflow in `JSON::Coder` when `as_json` returns a Symbol for a string with invalid encoding.
9+
* JRuby: fix `JSON::Coder` (and any frozen `JSON::State`) not being thread-safe. Each `#dump` call now uses its own nesting depth instead of mutating the shared state, which under concurrent use could make every later call raise `JSON::NestingError`.
910

1011
### 2026-10-13 (3.1.0)
1112

‎java/src/json/ext/Generator.java‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,9 @@ static <T extends IRubyObject> RubyString generateJson(ThreadContext context, T
5454
}
5555

5656
static <T extends IRubyObject> RubyString generateJson(ThreadContext context, T object, Handler<? super T> handler, IRubyObject arg0) {
57-
Session session = new Session(arg0);
58-
GeneratorState state = session.getState(context);
57+
GeneratorState state = GeneratorState.fromState(context, arg0 == null || arg0.isNil() ? null : arg0)
58+
.forGeneration(context);
59+
Session session = new Session(state);
5960
state.validateRfc8785(context);
6061
int depth = state.depth;
6162
try {
@@ -86,8 +87,8 @@ static <T extends IRubyObject> RubyString generateJson(ThreadContext context, T
8687
public static <T extends IRubyObject> IRubyObject
8788
generateJson(ThreadContext context, T object,
8889
GeneratorState config, IRubyObject io) {
89-
Session session = new Session(config);
90-
GeneratorState state = session.getState(context);
90+
GeneratorState state = config.forGeneration(context);
91+
Session session = new Session(state);
9192
state.validateRfc8785(context);
9293
int depth = state.depth;
9394

‎java/src/json/ext/GeneratorState.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -666,6 +666,20 @@ public RubyHash to_h(ThreadContext context) {
666666
return result;
667667
}
668668

669+
/**
670+
* Returns the state to mutate while generating a single document.
671+
*
672+
* <p>A frozen state (such as the one held by <code>JSON::Coder</code>) may be
673+
* shared by concurrent calls, so generating with it must not touch its
674+
* <code>depth</code>. In that case a private, unfrozen copy is returned.
675+
*/
676+
GeneratorState forGeneration(ThreadContext context) {
677+
if (!isFrozen()) return this;
678+
GeneratorState copy = new GeneratorState(context.runtime, getMetaClass());
679+
copy.initialize_copy(context, this);
680+
return copy;
681+
}
682+
669683
public int increaseDepth(ThreadContext context) {
670684
checkMaxNesting(context);
671685
return ++depth;

‎test/json/json_coder_test.rb‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,4 +259,72 @@ def test_nesting_recovery
259259
end
260260
assert_equal '{"a":1}', coder.dump({ a: 1 })
261261
end
262+
263+
def test_dump_concurrently
264+
coder = JSON::Coder.new
265+
expected = '{"a":{"b":[1,{"c":2}]}}'
266+
results = run_concurrently(20, 1000) { coder.dump({ a: { b: [1, { c: 2 }] } }) }
267+
assert_equal [expected], results.uniq
268+
assert_equal "[[]]", coder.dump([[]])
269+
end
270+
271+
def test_dump_concurrently_with_thread_pass
272+
custom = Struct.new(:value)
273+
coder = JSON::Coder.new do |object|
274+
Thread.pass
275+
object.value
276+
end
277+
expected = '{"a":[{"b":[1]},2]}'
278+
results = run_concurrently(10, 200) do
279+
coder.dump({ a: [{ b: [custom.new(1)] }, custom.new(2)] })
280+
end
281+
assert_equal [expected], results.uniq
282+
assert_equal "[[]]", coder.dump([[]])
283+
end
284+
285+
def test_dump_concurrently_with_depth
286+
coder = JSON::Coder.new(depth: 1, max_nesting: 3)
287+
results = run_concurrently(10, 500) { coder.dump([[1]]) }
288+
assert_equal ["[[1]]"], results.uniq
289+
assert_raise(JSON::NestingError) { coder.dump([[[1]]]) }
290+
end
291+
292+
def test_frozen_state_depth_unchanged
293+
state = JSON::State.new.freeze
294+
assert_equal "[[1]]", state.generate([[1]])
295+
assert_equal 0, state.depth
296+
297+
ary = []
298+
ary << ary
299+
assert_raise(JSON::NestingError) { state.generate(ary) }
300+
assert_equal 0, state.depth
301+
302+
results = run_concurrently(10, 500) { state.generate({ a: [1, { b: 2 }] }) }
303+
assert_equal ['{"a":[1,{"b":2}]}'], results.uniq
304+
assert_equal 0, state.depth
305+
end
306+
307+
private
308+
309+
def run_concurrently(thread_count, iterations)
310+
threads = thread_count.times.map do
311+
Thread.new do
312+
Thread.current.report_on_exception = false
313+
Array.new(iterations) { yield }
314+
end
315+
end
316+
317+
begin
318+
threads.flat_map(&:value)
319+
rescue
320+
threads.each do |thread|
321+
if thread.alive?
322+
thread.kill
323+
thread.join rescue nil
324+
end
325+
end
326+
327+
raise
328+
end
329+
end
262330
end

0 commit comments

Comments
 (0)