Skip to content

Commit 43bf62b

Browse files
committed
chore: Make materializer stackless for obj and arr
1 parent bac21d9 commit 43bf62b

2 files changed

Lines changed: 226 additions & 50 deletions

File tree

sjsonnet/src/sjsonnet/Materializer.scala

Lines changed: 224 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@ package sjsonnet
22

33
import sjsonnet.Expr.{FieldName, Member, ObjBody}
44
import sjsonnet.Expr.Member.Visibility
5-
import upickle.core.Visitor
5+
import upickle.core.{ArrVisitor, ObjVisitor, Visitor}
66

77
/**
88
* Serializes the given [[Val]] out to the given [[upickle.core.Visitor]], which can transform it
9-
* into [[ujson.Value]]s or directly serialize it to `String`s
9+
* into [[ujson.Value]]s or directly serialize it to `String`s.
10+
*
11+
* TCO boundary: all [[Val]] values entering materialization — whether from object field evaluation
12+
* (`Val.Obj.value`), array element forcing (`Val.Arr.value`), or top-level evaluation — must not
13+
* contain unresolved [[TailCall]] sentinels. This invariant is maintained by the evaluator: object
14+
* field `invoke` calls `visitExpr` (not `visitExprWithTailCallSupport`), and `Val.Func.apply*`
15+
* resolves TailCalls when called with `TailstrictModeDisabled`. A defensive check in
16+
* `materializeLeaf` guards against accidental TailCall leakage with a clear internal-error
17+
* diagnostic.
1018
*/
1119
abstract class Materializer {
1220
def storePos(pos: Position): Unit
@@ -17,64 +25,211 @@ abstract class Materializer {
1725
apply0(v, new sjsonnet.Renderer()).toString
1826
}
1927

20-
def apply0[T](v: Val, visitor: Visitor[T, T])(implicit evaluator: EvalScope): T = try {
28+
/**
29+
* Materialize a leaf value (non-container) to the given visitor. Callers must ensure that
30+
* container values (Obj/Arr) are never passed to this method — they are handled by the iterative
31+
* stack-based loop in [[materializeContainer]]. Passing a container will fall through to the
32+
* catch-all branch and throw an error.
33+
*/
34+
private def materializeLeaf[T](
35+
v: Val,
36+
visitor: Visitor[T, T])(implicit evaluator: EvalScope): T = {
2137
v match {
22-
case Val.Str(pos, s) => storePos(pos); visitor.visitString(s, -1)
23-
case obj: Val.Obj =>
24-
storePos(obj.pos)
25-
obj.triggerAllAsserts(evaluator.settings.brokenAssertionLogic)
26-
val objVisitor = visitor.visitObject(obj.visibleKeyNames.length, jsonableKeys = true, -1)
27-
val sort = !evaluator.settings.preserveOrder
28-
var prevKey: String = null
29-
obj.foreachElement(sort, evaluator.emptyMaterializeFileScopePos) { (k, v) =>
30-
storePos(v)
31-
objVisitor.visitKeyValue(objVisitor.visitKey(-1).visitString(k, -1))
32-
objVisitor.visitValue(
33-
apply0(v, objVisitor.subVisitor.asInstanceOf[Visitor[T, T]]),
34-
-1
35-
)
36-
if (sort) {
37-
if (prevKey != null && Util.compareStringsByCodepoint(k, prevKey) <= 0)
38-
Error.fail(
39-
s"""Internal error: Unexpected key "$k" after "$prevKey" in sorted object materialization""",
40-
v.pos
41-
)
42-
prevKey = k
43-
}
44-
}
45-
objVisitor.visitEnd(-1)
46-
case Val.Num(pos, _) => storePos(pos); visitor.visitFloat64(v.asDouble, -1)
47-
case xs: Val.Arr =>
48-
storePos(xs.pos)
49-
val arrVisitor = visitor.visitArray(xs.length, -1)
50-
var i = 0
51-
while (i < xs.length) {
52-
val sub = arrVisitor.subVisitor.asInstanceOf[Visitor[T, T]]
53-
arrVisitor.visitValue(apply0(xs.value(i), sub), -1)
54-
i += 1
55-
}
56-
arrVisitor.visitEnd(-1)
57-
case Val.True(pos) => storePos(pos); visitor.visitTrue(-1)
58-
case Val.False(pos) => storePos(pos); visitor.visitFalse(-1)
59-
case Val.Null(pos) => storePos(pos); visitor.visitNull(-1)
60-
case s: Val.Func =>
38+
case Val.Str(pos, s) => storePos(pos); visitor.visitString(s, -1)
39+
case Val.Num(pos, _) => storePos(pos); visitor.visitFloat64(v.asDouble, -1)
40+
case Val.True(pos) => storePos(pos); visitor.visitTrue(-1)
41+
case Val.False(pos) => storePos(pos); visitor.visitFalse(-1)
42+
case Val.Null(pos) => storePos(pos); visitor.visitNull(-1)
43+
case mat: Materializer.Materializable => storePos(v.pos); mat.materialize(visitor)
44+
case s: Val.Func =>
6145
Error.fail(
6246
"Couldn't manifest function with params [" + s.params.names.mkString(",") + "]",
6347
v.pos
6448
)
65-
case mat: Materializer.Materializable => storePos(v.pos); mat.materialize(visitor)
66-
case vv: Val =>
49+
case tc: TailCall =>
50+
Error.fail(
51+
"Internal error: TailCall sentinel leaked into materialization. " +
52+
"This indicates a bug in the TCO protocol — a TailCall was not resolved before " +
53+
"reaching the Materializer.",
54+
tc.pos
55+
)
56+
case vv: Val =>
6757
Error.fail("Unknown value type " + vv.prettyName, vv.pos)
6858
case null =>
6959
Error.fail("Unknown value type " + v)
7060
}
71-
} catch {
72-
case _: StackOverflowError =>
73-
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
74-
case _: OutOfMemoryError =>
75-
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
7661
}
7762

63+
/**
64+
* Iterative materialization using an explicit stack. Replaces the previous recursive
65+
* implementation to eliminate StackOverflowError on deeply nested JSON structures.
66+
*
67+
* The stack holds frames representing in-progress container (object/array) materializations. Each
68+
* iteration either:
69+
* 1. Processes the next child of the current container, pushing a new frame if the child is
70+
* itself a container.
71+
* 2. Completes the current container (visitEnd) and pops the frame, feeding the result back to
72+
* the parent frame via visitValue.
73+
*
74+
* A depth limit guards against infinite recursion from self-referential values (e.g.
75+
* `local L = [L]; L`), throwing a descriptive error instead of OOM.
76+
*/
77+
def apply0[T](v: Val, visitor: Visitor[T, T])(implicit evaluator: EvalScope): T = v match {
78+
case obj: Val.Obj => materializeContainer(obj, visitor)
79+
case xs: Val.Arr => materializeContainer(xs, visitor)
80+
case _ => materializeLeaf(v, visitor)
81+
}
82+
83+
// Iterative materialization for container values (Obj/Arr). Separated from apply0 to keep
84+
// the fast-path method small and inlineable by the JIT.
85+
//
86+
// Design: the loop processes one child per iteration using pattern matching on the sealed trait
87+
// MaterializeFrame. Helper methods (pushObjFrame, pushArrFrame, materializeChild, feedResult)
88+
// are kept small so the JIT can unconditionally inline them (< 35 bytes each), giving the same
89+
// effect as manual inlining while keeping the main loop compact enough for C2 deep optimization.
90+
private def materializeContainer[T](v: Val, visitor: Visitor[T, T])(implicit
91+
evaluator: EvalScope): T = {
92+
try {
93+
val maxDepth = evaluator.settings.maxMaterializeDepth
94+
val sort = !evaluator.settings.preserveOrder
95+
val brokenAssertionLogic = evaluator.settings.brokenAssertionLogic
96+
val emptyPos = evaluator.emptyMaterializeFileScopePos
97+
val stack = new java.util.ArrayDeque[Materializer.MaterializeFrame](8)
98+
99+
// Push the initial container frame
100+
v match {
101+
case obj: Val.Obj => pushObjFrame(obj, visitor, stack, maxDepth, sort, brokenAssertionLogic)
102+
case xs: Val.Arr => pushArrFrame(xs, visitor, stack, maxDepth)
103+
case _ => () // unreachable — apply0 guarantees Obj or Arr
104+
}
105+
106+
while (true) {
107+
stack.peekFirst() match {
108+
case frame: Materializer.MaterializeObjFrame[T @unchecked] =>
109+
val keys = frame.keys
110+
val ov = frame.objVisitor
111+
if (frame.index < keys.length) {
112+
val key = keys(frame.index)
113+
val childVal = frame.obj.value(key, emptyPos)
114+
storePos(childVal)
115+
116+
if (frame.sort) {
117+
if (
118+
frame.prevKey != null && Util.compareStringsByCodepoint(key, frame.prevKey) <= 0
119+
)
120+
Error.fail(
121+
s"""Internal error: Unexpected key "$key" after "${frame.prevKey}" in sorted object materialization""",
122+
childVal.pos
123+
)
124+
frame.prevKey = key
125+
}
126+
127+
ov.visitKeyValue(ov.visitKey(-1).visitString(key, -1))
128+
frame.index += 1
129+
130+
val sub = ov.subVisitor.asInstanceOf[Visitor[T, T]]
131+
materializeChild(childVal, sub, ov, stack, maxDepth, sort, brokenAssertionLogic)
132+
} else {
133+
val result = ov.visitEnd(-1)
134+
stack.removeFirst()
135+
if (stack.isEmpty) return result
136+
feedResult(stack.peekFirst(), result)
137+
}
138+
139+
case frame: Materializer.MaterializeArrFrame[T @unchecked] =>
140+
val arr = frame.arr
141+
val av = frame.arrVisitor
142+
if (frame.index < arr.length) {
143+
val childVal = arr.value(frame.index)
144+
frame.index += 1
145+
146+
val sub = av.subVisitor.asInstanceOf[Visitor[T, T]]
147+
materializeChild(childVal, sub, av, stack, maxDepth, sort, brokenAssertionLogic)
148+
} else {
149+
val result = av.visitEnd(-1)
150+
stack.removeFirst()
151+
if (stack.isEmpty) return result
152+
feedResult(stack.peekFirst(), result)
153+
}
154+
}
155+
}
156+
157+
null.asInstanceOf[T] // unreachable — while(true) exits via return
158+
} catch {
159+
case _: StackOverflowError =>
160+
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
161+
case _: OutOfMemoryError =>
162+
Error.fail("Out of memory while materializing, possibly due to recursive value", v.pos)
163+
}
164+
}
165+
166+
// Materialize a child value: leaf fast-path avoids a full pattern match for the common case
167+
// (strings, numbers, booleans, null). Only containers (Obj/Arr) push a new frame.
168+
private def materializeChild[T](
169+
childVal: Val,
170+
childVisitor: Visitor[T, T],
171+
parentVisitor: upickle.core.ObjArrVisitor[T, T],
172+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
173+
maxDepth: Int,
174+
sort: Boolean,
175+
brokenAssertionLogic: Boolean)(implicit evaluator: EvalScope): Unit = {
176+
if (!childVal.isInstanceOf[Val.Obj] && !childVal.isInstanceOf[Val.Arr]) {
177+
parentVisitor.visitValue(materializeLeaf(childVal, childVisitor), -1)
178+
} else
179+
childVal match {
180+
case obj: Val.Obj =>
181+
pushObjFrame(obj, childVisitor, stack, maxDepth, sort, brokenAssertionLogic)
182+
case xs: Val.Arr =>
183+
pushArrFrame(xs, childVisitor, stack, maxDepth)
184+
case _ => () // unreachable — guarded by isInstanceOf checks above
185+
}
186+
}
187+
188+
private def pushObjFrame[T](
189+
obj: Val.Obj,
190+
visitor: Visitor[T, T],
191+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
192+
maxDepth: Int,
193+
sort: Boolean,
194+
brokenAssertionLogic: Boolean)(implicit evaluator: EvalScope): Unit = {
195+
checkDepth(obj.pos, stack.size, maxDepth)
196+
storePos(obj.pos)
197+
obj.triggerAllAsserts(brokenAssertionLogic)
198+
val keyNames =
199+
if (sort) obj.visibleKeyNames.sorted(Util.CodepointStringOrdering)
200+
else obj.visibleKeyNames
201+
val objVisitor = visitor.visitObject(keyNames.length, jsonableKeys = true, -1)
202+
stack.push(new Materializer.MaterializeObjFrame[T](objVisitor, keyNames, obj, sort, 0, null))
203+
}
204+
205+
private def pushArrFrame[T](
206+
xs: Val.Arr,
207+
visitor: Visitor[T, T],
208+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
209+
maxDepth: Int)(implicit evaluator: EvalScope): Unit = {
210+
checkDepth(xs.pos, stack.size, maxDepth)
211+
storePos(xs.pos)
212+
val arrVisitor = visitor.visitArray(xs.length, -1)
213+
stack.push(new Materializer.MaterializeArrFrame[T](arrVisitor, xs, 0))
214+
}
215+
216+
// Feed a completed child result into the parent frame's visitor.
217+
private def feedResult[T](parentFrame: Materializer.MaterializeFrame, result: T): Unit =
218+
parentFrame match {
219+
case f: Materializer.MaterializeObjFrame[T @unchecked] =>
220+
f.objVisitor.visitValue(result, -1)
221+
case f: Materializer.MaterializeArrFrame[T @unchecked] =>
222+
f.arrVisitor.visitValue(result, -1)
223+
}
224+
225+
private def checkDepth(pos: Position, stackSize: Int, maxDepth: Int)(implicit
226+
ev: EvalErrorScope): Unit =
227+
if (stackSize >= maxDepth)
228+
Error.fail(
229+
"Stackoverflow while materializing, possibly due to recursive value",
230+
pos
231+
)
232+
78233
def reverse(pos: Position, v: ujson.Value): Val = v match {
79234
case ujson.True => Val.True(pos)
80235
case ujson.False => Val.False(pos)
@@ -149,6 +304,26 @@ object Materializer extends Materializer {
149304
final val emptyStringArray = new Array[String](0)
150305
final val emptyLazyArray = new Array[Eval](0)
151306

307+
/** Common parent for stack frames used in iterative materialization. */
308+
private[sjsonnet] sealed trait MaterializeFrame
309+
310+
/** Stack frame for in-progress object materialization. */
311+
private[sjsonnet] final class MaterializeObjFrame[T](
312+
val objVisitor: ObjVisitor[T, T],
313+
val keys: Array[String],
314+
val obj: Val.Obj,
315+
val sort: Boolean,
316+
var index: Int,
317+
var prevKey: String)
318+
extends MaterializeFrame
319+
320+
/** Stack frame for in-progress array materialization. */
321+
private[sjsonnet] final class MaterializeArrFrame[T](
322+
val arrVisitor: ArrVisitor[T, T],
323+
val arr: Val.Arr,
324+
var index: Int)
325+
extends MaterializeFrame
326+
152327
/**
153328
* Trait for providing custom materialization logic to the Materializer.
154329
* @since 1.0.0

sjsonnet/src/sjsonnet/Settings.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ final case class Settings(
1010
throwErrorForInvalidSets: Boolean = false,
1111
useNewEvaluator: Boolean = false,
1212
maxParserRecursionDepth: Int = 1000,
13-
brokenAssertionLogic: Boolean = false
13+
brokenAssertionLogic: Boolean = false,
14+
maxMaterializeDepth: Int = 1000
1415
)
1516

1617
object Settings {

0 commit comments

Comments
 (0)