From fb00da6d57b01f5082ff50c1f61db75445a87354 Mon Sep 17 00:00:00 2001 From: Josh Lawrence Date: Tue, 6 Oct 2026 11:48:42 +0100 Subject: [PATCH 1/2] Fix Timebox depth leak on array/object constructors #120 The array constructor and evaluateGroupExpression set environment.isParallelCall for every item after the first but never reset it, so the exit callback for the constructor itself was skipped and Timebox depth grew by one per evaluation. Co-Authored-By: Claude Opus 5.5 --- .../java/com/dashjoin/jsonata/Jsonata.java | 4 +++ .../com/dashjoin/jsonata/RuntimeTest.java | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/main/java/com/dashjoin/jsonata/Jsonata.java b/src/main/java/com/dashjoin/jsonata/Jsonata.java index 575279b..03c80ce 100644 --- a/src/main/java/com/dashjoin/jsonata/Jsonata.java +++ b/src/main/java/com/dashjoin/jsonata/Jsonata.java @@ -662,6 +662,7 @@ public Object call() throws Exception { // array constructor - evaluate each item result = new JList<>(); // []; int idx = 0; + boolean wasParallelCall = environment.isParallelCall; for (var item : expr.expressions) { environment.isParallelCall = idx > 0; Object value = evaluate(item, input, environment); @@ -673,6 +674,7 @@ public Object call() throws Exception { } idx++; } + environment.isParallelCall = wasParallelCall; if(expr.consarray) { if (!(result instanceof JList)) result = new JList((List)result); @@ -1117,6 +1119,7 @@ static class GroupEntry { // iterate over the groups to evaluate the "value" expression //let generators = /* await */ Promise.all(Object.keys(groups).map(/* async */ (key, idx) => { int idx = 0; + boolean wasParallelCall = environment.isParallelCall; for (Entry e : groups.entrySet()) { var entry = e.getValue(); var context = entry.data; @@ -1135,6 +1138,7 @@ static class GroupEntry { idx++; } + environment.isParallelCall = wasParallelCall; // for (let generator of generators) { // var [key, value] = /* await */ generator; diff --git a/src/test/java/com/dashjoin/jsonata/RuntimeTest.java b/src/test/java/com/dashjoin/jsonata/RuntimeTest.java index dcc1bfe..7128a7a 100644 --- a/src/test/java/com/dashjoin/jsonata/RuntimeTest.java +++ b/src/test/java/com/dashjoin/jsonata/RuntimeTest.java @@ -24,6 +24,41 @@ public void testRuntimeBounds() { expr.evaluate(null, frame); } + /** + * https://github.com/dashjoin/jsonata-java/issues/120 + */ + @Test + public void testRuntimeBoundsArrayConstructorInLoop() { + var expr = jsonata("$map([1..30], function($i) { [$i, $i] })"); + var frame = expr.createFrame(); + frame.setRuntimeBounds(1000, 10); + expr.evaluate(null, frame); + } + + @Test + public void testRuntimeBoundsNestedArrayConstructorInLoop() { + var expr = jsonata("$map([1..30], function($i) { [$i, [$i, $i], $i] })"); + var frame = expr.createFrame(); + frame.setRuntimeBounds(1000, 10); + expr.evaluate(null, frame); + } + + @Test + public void testRuntimeBoundsObjectConstructorInLoop() { + var expr = jsonata("$map([1..30], function($i) { {'a': $i, 'b': $i} })"); + var frame = expr.createFrame(); + frame.setRuntimeBounds(1000, 10); + expr.evaluate(null, frame); + } + + @Test + public void testRuntimeBoundsRecursionWithArrayConstructor() { + var expr = jsonata("($f := function($n) { $n > 0 ? [$n, $f($n - 1)] : [] }; $f(30))"); + var frame = expr.createFrame(); + frame.setRuntimeBounds(1000, 10); + Assertions.assertThrows(JException.class, () -> expr.evaluate(null, frame)); + } + boolean entered = false; boolean exited = false; From c3e816722219d805241fc003d76e51a4010f4026 Mon Sep 17 00:00:00 2001 From: Josh Lawrence Date: Tue, 6 Oct 2026 11:56:31 +0100 Subject: [PATCH 2/2] Assert Timebox depth returns to zero after constructors #120 Co-Authored-By: Claude Opus 5.5 --- src/test/java/com/dashjoin/jsonata/RuntimeTest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/test/java/com/dashjoin/jsonata/RuntimeTest.java b/src/test/java/com/dashjoin/jsonata/RuntimeTest.java index 7128a7a..58c1c4c 100644 --- a/src/test/java/com/dashjoin/jsonata/RuntimeTest.java +++ b/src/test/java/com/dashjoin/jsonata/RuntimeTest.java @@ -51,6 +51,19 @@ public void testRuntimeBoundsObjectConstructorInLoop() { expr.evaluate(null, frame); } + /** + * restoring isParallelCall to false instead of its previous value would let depth go negative, + * silently loosening the bound without ever overflowing + */ + @Test + public void testRuntimeBoundsDepthBalancedAfterConstructors() { + var expr = jsonata("$map([1..30], function($i) { [$i, [$i, $i], {'a': $i, 'b': [$i, $i]}] })"); + var frame = expr.createFrame(); + var timebox = new Timebox(frame, 1000, 10); + expr.evaluate(null, frame); + Assertions.assertEquals(0, timebox.depth); + } + @Test public void testRuntimeBoundsRecursionWithArrayConstructor() { var expr = jsonata("($f := function($n) { $n > 0 ? [$n, $f($n - 1)] : [] }; $f(30))");