From 7651bb79a9e8dbff09af143d55cb05220c1fec73 Mon Sep 17 00:00:00 2001 From: He-Pin Date: Tue, 12 May 2026 14:31:14 +0800 Subject: [PATCH] fix: preserve byte renderer fallback context Motivation: ByteRenderer's direct materialization fallback could restart stackless materialization with a fresh MaterializeContext, losing the objects already being rendered on the active path. That delayed recursive-object detection and could duplicate partial output before reporting the cycle. Modification: Thread the existing MaterializeContext into the stackless fallback for deep arrays and objects, and widen materializeStackless to package-private so ByteRenderer can reuse it. Add a regression test that distinguishes the fixed behavior from the old duplicate-render path. Result: Deep ByteRenderer fallback keeps cycle tracking and materialization settings consistent with the surrounding render pass while preserving the existing recursive error behavior. References: Source material split from render optimization review in PR #776. --- sjsonnet/src/sjsonnet/ByteRenderer.scala | 4 +-- sjsonnet/src/sjsonnet/Materializer.scala | 2 +- .../test/src/sjsonnet/RendererTests.scala | 31 +++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) diff --git a/sjsonnet/src/sjsonnet/ByteRenderer.scala b/sjsonnet/src/sjsonnet/ByteRenderer.scala index f10b180c..ad65bdbd 100644 --- a/sjsonnet/src/sjsonnet/ByteRenderer.scala +++ b/sjsonnet/src/sjsonnet/ByteRenderer.scala @@ -222,13 +222,13 @@ class ByteRenderer(out: OutputStream = new java.io.ByteArrayOutputStream(), inde materializeDirectArr(xs, matDepth + 1, ctx) else // Fall back to generic visitor path for extremely deep nesting - Materializer.apply0(v, this)(evaluator) + Materializer.materializeStackless(v, this, ctx)(evaluator) case 6 => // TAG_OBJ val obj = v.asInstanceOf[Val.Obj] if (matDepth < ctx.recursiveDepthLimit) materializeDirectObj(obj, matDepth + 1, ctx) else - Materializer.apply0(v, this)(evaluator) + Materializer.materializeStackless(v, this, ctx)(evaluator) case 7 => // TAG_FUNC val s = v.asInstanceOf[Val.Func] Error.fail( diff --git a/sjsonnet/src/sjsonnet/Materializer.scala b/sjsonnet/src/sjsonnet/Materializer.scala index f168b3d0..9892e3da 100644 --- a/sjsonnet/src/sjsonnet/Materializer.scala +++ b/sjsonnet/src/sjsonnet/Materializer.scala @@ -333,7 +333,7 @@ abstract class Materializer { // Iterative materialization for deep nesting. Used as a fallback when recursive depth exceeds // the recursive depth limit. Uses an explicit ArrayDeque stack to avoid StackOverflowError. - private def materializeStackless[T]( + private[sjsonnet] def materializeStackless[T]( v: Val, visitor: Visitor[T, T], ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): T = { diff --git a/sjsonnet/test/src/sjsonnet/RendererTests.scala b/sjsonnet/test/src/sjsonnet/RendererTests.scala index 65577f25..a06c58af 100644 --- a/sjsonnet/test/src/sjsonnet/RendererTests.scala +++ b/sjsonnet/test/src/sjsonnet/RendererTests.scala @@ -1,5 +1,6 @@ package sjsonnet +import java.io.ByteArrayOutputStream import utest._ object RendererTests extends TestSuite { @@ -65,6 +66,36 @@ object RendererTests extends TestSuite { ujson.transform(ujson.Num(1e15), new Renderer()).toString ==> "1000000000000000" } + test("byteRendererFallbackPreservesCycleContext") { + val interpreter = new Interpreter( + Map(), + Map(), + DummyPath(), + Importer.empty, + parseCache = new DefaultParseCache, + settings = Settings.default.copy(materializeRecursiveDepthLimit = 2, maxMaterializeDepth = 2) + ) + val value = interpreter.evaluate( + """local o = { + | a: std.repeat("x", 10000), + | z: { b: o }, + |}; + |{ root: o }""".stripMargin, + DummyPath("(memory)") + ) match { + case Right(v) => v + case Left(err) => throw new Exception(Error.formatError(err)) + } + val out = new ByteArrayOutputStream + val e = interpreter.materialize(value, new ByteRenderer(out)) match { + case Left(err) => Error.formatError(err) + case Right(_) => throw new Exception("Expected recursive value materialization error") + } + assert(e.contains("Stackoverflow while materializing, possibly due to recursive value")) + // Losing the outer materialization context re-renders `o.a` before detecting the cycle. + assert(out.size() < 15000) + } + test("indentZero") { // indent=0 should produce newlines but no spaces ujson.transform(ujson.Arr(1, 2), new Renderer(indent = 0)).toString ==>