Skip to content

Commit b282d75

Browse files
committed
chore: Make materializer stackless for obj and arr
1 parent 415524b commit b282d75

5 files changed

Lines changed: 523 additions & 44 deletions

File tree

readme.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,25 @@ To generate the static files without starting a server:
292292

293293
The output is a single `index.html` file (with the Sjsonnet JS engine inlined) written to `out/playground/bundle.dest/`. You can open it directly in a browser — no server needed — or serve it with any static file server. The editor UI (CodeMirror) is loaded from a CDN at runtime, so an internet connection is required.
294294

295+
## development
296+
To compile the JVM version:
297+
298+
```bash
299+
./mill 'sjsonnet.jvm[3.3.7]'.compile
300+
```
301+
302+
To run the tests:
303+
304+
```bash
305+
./mill 'sjsonnet.jvm[3.3.7]'.test
306+
```
307+
308+
If you want test for all platforms and versions, you can run
309+
310+
```bash
311+
./mill __.test
312+
```
313+
295314
## Client-Server
296315

297316
Sjsonnet comes with a built in thin-client and background server, to help

sjsonnet/src/sjsonnet/Materializer.scala

Lines changed: 289 additions & 43 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,43 +25,18 @@ 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
31+
* [[materializeRecursiveObj]]/[[materializeRecursiveArr]] (recursive mode) or
32+
* [[materializeIterative]] (iterative fallback). Passing a container will fall through to the
33+
* catch-all branch and throw an error.
34+
*/
35+
@inline private def materializeLeaf[T](v: Val, visitor: Visitor[T, T])(implicit
36+
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)
38+
case Val.Str(pos, s) => storePos(pos); visitor.visitString(s, -1)
39+
case Val.Num(pos, _) => storePos(pos); visitor.visitFloat64(v.asDouble, -1)
5740
case Val.True(pos) => storePos(pos); visitor.visitTrue(-1)
5841
case Val.False(pos) => storePos(pos); visitor.visitFalse(-1)
5942
case Val.Null(pos) => storePos(pos); visitor.visitNull(-1)
@@ -75,13 +58,234 @@ abstract class Materializer {
7558
case null =>
7659
Error.fail("Unknown value type " + v)
7760
}
78-
} catch {
79-
case _: StackOverflowError =>
80-
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
81-
case _: OutOfMemoryError =>
82-
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
8361
}
8462

63+
/**
64+
* Hybrid materialization: uses JVM stack recursion for shallow nesting (zero heap allocation,
65+
* JIT-friendly) and automatically switches to an explicit stack-based iterative loop when the
66+
* recursion depth exceeds [[Settings.materializeRecursiveDepthLimit]].
67+
*/
68+
def apply0[T](v: Val, visitor: Visitor[T, T])(implicit evaluator: EvalScope): T = {
69+
try {
70+
val ctx = Materializer.MaterializeContext(evaluator)
71+
v match {
72+
case obj: Val.Obj => materializeRecursiveObj(obj, visitor, 0, ctx)
73+
case xs: Val.Arr => materializeRecursiveArr(xs, visitor, 0, ctx)
74+
case _ => materializeLeaf(v, visitor)
75+
}
76+
} catch {
77+
case _: StackOverflowError =>
78+
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
79+
case _: OutOfMemoryError =>
80+
Error.fail("Out of memory while materializing, possibly due to recursive value", v.pos)
81+
}
82+
}
83+
84+
@inline private def materializeRecursiveObj[T](
85+
obj: Val.Obj,
86+
visitor: Visitor[T, T],
87+
depth: Int,
88+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): T = {
89+
storePos(obj.pos)
90+
obj.triggerAllAsserts(ctx.brokenAssertionLogic)
91+
val keys =
92+
if (ctx.sort) obj.visibleKeyNames.sorted(Util.CodepointStringOrdering)
93+
else obj.visibleKeyNames
94+
val ov = visitor.visitObject(keys.length, jsonableKeys = true, -1)
95+
var i = 0
96+
var prevKey: String = null
97+
while (i < keys.length) {
98+
val key = keys(i)
99+
val childVal = obj.value(key, ctx.emptyPos)
100+
storePos(childVal)
101+
if (ctx.sort) {
102+
if (prevKey != null && Util.compareStringsByCodepoint(key, prevKey) <= 0)
103+
Error.fail(
104+
s"""Internal error: Unexpected key "$key" after "$prevKey" in sorted object materialization""",
105+
childVal.pos
106+
)
107+
prevKey = key
108+
}
109+
ov.visitKeyValue(ov.visitKey(-1).visitString(key, -1))
110+
val sub = ov.subVisitor.asInstanceOf[Visitor[T, T]]
111+
ov.visitValue(materializeRecursiveChild(childVal, sub, depth, ctx), -1)
112+
i += 1
113+
}
114+
ov.visitEnd(-1)
115+
}
116+
117+
@inline private def materializeRecursiveArr[T](
118+
xs: Val.Arr,
119+
visitor: Visitor[T, T],
120+
depth: Int,
121+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): T = {
122+
storePos(xs.pos)
123+
val av = visitor.visitArray(xs.length, -1)
124+
var i = 0
125+
while (i < xs.length) {
126+
val childVal = xs.value(i)
127+
av.visitValue(
128+
materializeRecursiveChild(childVal, av.subVisitor.asInstanceOf[Visitor[T, T]], depth, ctx),
129+
-1
130+
)
131+
i += 1
132+
}
133+
av.visitEnd(-1)
134+
}
135+
136+
@inline private def materializeRecursiveChild[T](
137+
childVal: Val,
138+
childVisitor: Visitor[T, T],
139+
depth: Int,
140+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): T = childVal match {
141+
case obj: Val.Obj =>
142+
val nextDepth = depth + 1
143+
if (nextDepth < ctx.recursiveDepthLimit)
144+
materializeRecursiveObj(obj, childVisitor, nextDepth, ctx)
145+
else
146+
materializeStackless(childVal, childVisitor, ctx)
147+
case xs: Val.Arr =>
148+
val nextDepth = depth + 1
149+
if (nextDepth < ctx.recursiveDepthLimit)
150+
materializeRecursiveArr(xs, childVisitor, nextDepth, ctx)
151+
else
152+
materializeStackless(childVal, childVisitor, ctx)
153+
case _ =>
154+
materializeLeaf(childVal, childVisitor)
155+
}
156+
157+
// Iterative materialization for deep nesting. Used as a fallback when recursive depth exceeds
158+
// the recursive depth limit. Uses an explicit ArrayDeque stack to avoid StackOverflowError.
159+
private def materializeStackless[T](
160+
v: Val,
161+
visitor: Visitor[T, T],
162+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): T = {
163+
val stack = new java.util.ArrayDeque[Materializer.MaterializeFrame](
164+
Math.max(16, Math.min(ctx.recursiveDepthLimit * 4, 8192))
165+
)
166+
167+
// Push the initial container frame
168+
v match {
169+
case obj: Val.Obj => pushObjFrame(obj, visitor, stack, ctx)
170+
case xs: Val.Arr => pushArrFrame(xs, visitor, stack, ctx)
171+
case _ => () // unreachable
172+
}
173+
174+
while (true) {
175+
stack.peekFirst() match {
176+
case frame: Materializer.MaterializeObjFrame[T @unchecked] =>
177+
val keys = frame.keys
178+
val ov = frame.objVisitor
179+
if (frame.index < keys.length) {
180+
val key = keys(frame.index)
181+
val childVal = frame.obj.value(key, ctx.emptyPos)
182+
storePos(childVal)
183+
184+
if (frame.sort) {
185+
if (frame.prevKey != null && Util.compareStringsByCodepoint(key, frame.prevKey) <= 0)
186+
Error.fail(
187+
s"""Internal error: Unexpected key "$key" after "${frame.prevKey}" in sorted object materialization""",
188+
childVal.pos
189+
)
190+
frame.prevKey = key
191+
}
192+
193+
ov.visitKeyValue(ov.visitKey(-1).visitString(key, -1))
194+
frame.index += 1
195+
196+
val sub = ov.subVisitor.asInstanceOf[Visitor[T, T]]
197+
materializeChild(childVal, sub, ov, stack, ctx)
198+
} else {
199+
val result = ov.visitEnd(-1)
200+
stack.removeFirst()
201+
if (stack.isEmpty) return result
202+
feedResult(stack.peekFirst(), result)
203+
}
204+
205+
case frame: Materializer.MaterializeArrFrame[T @unchecked] =>
206+
val arr = frame.arr
207+
val av = frame.arrVisitor
208+
if (frame.index < arr.length) {
209+
val childVal = arr.value(frame.index)
210+
frame.index += 1
211+
212+
val sub = av.subVisitor.asInstanceOf[Visitor[T, T]]
213+
materializeChild(childVal, sub, av, stack, ctx)
214+
} else {
215+
val result = av.visitEnd(-1)
216+
stack.removeFirst()
217+
if (stack.isEmpty) return result
218+
feedResult(stack.peekFirst(), result)
219+
}
220+
}
221+
}
222+
223+
null.asInstanceOf[T] // unreachable — while(true) exits via return
224+
}
225+
226+
// Materialize a child value in iterative mode. Single match dispatches leaf values directly
227+
// and pushes a new stack frame for containers. Avoids redundant isInstanceOf pre-checks.
228+
@inline private def materializeChild[T](
229+
childVal: Val,
230+
childVisitor: Visitor[T, T],
231+
parentVisitor: upickle.core.ObjArrVisitor[T, T],
232+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
233+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): Unit = {
234+
childVal match {
235+
case obj: Val.Obj =>
236+
pushObjFrame(obj, childVisitor, stack, ctx)
237+
case xs: Val.Arr =>
238+
pushArrFrame(xs, childVisitor, stack, ctx)
239+
case _ =>
240+
parentVisitor.visitValue(materializeLeaf(childVal, childVisitor), -1)
241+
}
242+
}
243+
244+
@inline private def pushObjFrame[T](
245+
obj: Val.Obj,
246+
visitor: Visitor[T, T],
247+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
248+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): Unit = {
249+
checkDepth(obj.pos, stack.size, ctx.maxDepth)
250+
storePos(obj.pos)
251+
obj.triggerAllAsserts(ctx.brokenAssertionLogic)
252+
val keyNames =
253+
if (ctx.sort) obj.visibleKeyNames.sorted(Util.CodepointStringOrdering)
254+
else obj.visibleKeyNames
255+
val objVisitor = visitor.visitObject(keyNames.length, jsonableKeys = true, -1)
256+
stack.push(
257+
new Materializer.MaterializeObjFrame[T](objVisitor, keyNames, obj, ctx.sort, 0, null)
258+
)
259+
}
260+
261+
@inline private def pushArrFrame[T](
262+
xs: Val.Arr,
263+
visitor: Visitor[T, T],
264+
stack: java.util.ArrayDeque[Materializer.MaterializeFrame],
265+
ctx: Materializer.MaterializeContext)(implicit evaluator: EvalScope): Unit = {
266+
checkDepth(xs.pos, stack.size, ctx.maxDepth)
267+
storePos(xs.pos)
268+
val arrVisitor = visitor.visitArray(xs.length, -1)
269+
stack.push(new Materializer.MaterializeArrFrame[T](arrVisitor, xs, 0))
270+
}
271+
272+
// Feed a completed child result into the parent frame's visitor.
273+
@inline private def feedResult[T](parentFrame: Materializer.MaterializeFrame, result: T): Unit =
274+
parentFrame match {
275+
case f: Materializer.MaterializeObjFrame[T @unchecked] =>
276+
f.objVisitor.visitValue(result, -1)
277+
case f: Materializer.MaterializeArrFrame[T @unchecked] =>
278+
f.arrVisitor.visitValue(result, -1)
279+
}
280+
281+
@inline private def checkDepth(pos: Position, stackSize: Int, maxDepth: Int)(implicit
282+
ev: EvalErrorScope): Unit =
283+
if (stackSize >= maxDepth)
284+
Error.fail(
285+
"Stackoverflow while materializing, possibly due to recursive value",
286+
pos
287+
)
288+
85289
def reverse(pos: Position, v: ujson.Value): Val = v match {
86290
case ujson.True => Val.True(pos)
87291
case ujson.False => Val.False(pos)
@@ -156,6 +360,48 @@ object Materializer extends Materializer {
156360
final val emptyStringArray = new Array[String](0)
157361
final val emptyLazyArray = new Array[Eval](0)
158362

363+
/**
364+
* Immutable snapshot of all settings needed during a single materialization pass. Created once
365+
* per top-level call and threaded through recursive/iterative helpers, avoiding repeated field
366+
* lookups on the [[Settings]] object on every frame.
367+
*/
368+
private[sjsonnet] final class MaterializeContext(
369+
val sort: Boolean,
370+
val brokenAssertionLogic: Boolean,
371+
val emptyPos: Position,
372+
val recursiveDepthLimit: Int,
373+
val maxDepth: Int)
374+
375+
private[sjsonnet] object MaterializeContext {
376+
def apply(ev: EvalScope): MaterializeContext = new MaterializeContext(
377+
sort = !ev.settings.preserveOrder,
378+
brokenAssertionLogic = ev.settings.brokenAssertionLogic,
379+
emptyPos = ev.emptyMaterializeFileScopePos,
380+
recursiveDepthLimit = ev.settings.materializeRecursiveDepthLimit,
381+
maxDepth = ev.settings.maxMaterializeDepth
382+
)
383+
}
384+
385+
/** Common parent for stack frames used in iterative materialization. */
386+
private[sjsonnet] sealed trait MaterializeFrame
387+
388+
/** Stack frame for in-progress object materialization. */
389+
private[sjsonnet] final class MaterializeObjFrame[T](
390+
val objVisitor: ObjVisitor[T, T],
391+
val keys: Array[String],
392+
val obj: Val.Obj,
393+
val sort: Boolean,
394+
var index: Int,
395+
var prevKey: String)
396+
extends MaterializeFrame
397+
398+
/** Stack frame for in-progress array materialization. */
399+
private[sjsonnet] final class MaterializeArrFrame[T](
400+
val arrVisitor: ArrVisitor[T, T],
401+
val arr: Val.Arr,
402+
var index: Int)
403+
extends MaterializeFrame
404+
159405
/**
160406
* Trait for providing custom materialization logic to the Materializer.
161407
* @since 1.0.0

0 commit comments

Comments
 (0)