Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
05ed13c
Add more test cases covering existing behaviors.
JoshRosen May 6, 2025
280b679
Add (failing) regression test for reported bug.
JoshRosen May 6, 2025
2b76aeb
Fix reported bug.
JoshRosen May 6, 2025
64edef1
Add (failing) regression test for related prexisting bug for super refs.
JoshRosen May 6, 2025
ee94db1
Fix related bug for super refs.
JoshRosen May 6, 2025
7a281cb
Add (failing) regression test for invalid obj comp key types.
JoshRosen May 6, 2025
856c193
Error on invalid obj comp key types.
JoshRosen May 6, 2025
3239399
Remove now-unused newSelf variable.
JoshRosen May 6, 2025
1c51f11
Add missing useNewEvaluator in eval.
JoshRosen May 6, 2025
55bcb26
Add regression test for another `super` bug.
JoshRosen May 6, 2025
fd038d8
Fix super in value calculations.
JoshRosen May 6, 2025
b74a493
Simplify thunks / laziness in ValScope.extend() and Evaluator.visitBi…
JoshRosen May 6, 2025
fd3e9f4
remove one unnecessary local
JoshRosen May 6, 2025
cb5593e
scalafmt fixes
JoshRosen May 6, 2025
bd4e1b2
DRY up the error message.
JoshRosen May 7, 2025
f5e40c2
See if inlining method avoids over-sensitivity in stack overflow test.
JoshRosen May 7, 2025
4867920
Revert "See if inlining method avoids over-sensitivity in stack overf…
JoshRosen May 7, 2025
63cad73
Revert "DRY up the error message."
JoshRosen May 7, 2025
7df617e
DRY up the error message.
JoshRosen May 7, 2025
e06e7a7
Support function definitions via object comprehensions.
JoshRosen May 7, 2025
5cd4ed8
Hack to address CI flakiness.
JoshRosen May 7, 2025
0b2fce2
Add comment calling out circular reference
JoshRosen May 7, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 30 additions & 32 deletions sjsonnet/src/sjsonnet/Evaluator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -841,42 +841,40 @@ class Evaluator(
def visitObjComp(e: ObjBody.ObjComp, sup: Val.Obj)(implicit scope: ValScope): Val.Obj = {
val binds = e.preLocals ++ e.postLocals
val compScope: ValScope = scope // .clearSuper

lazy val newSelf: Val.Obj = {
val builder = new java.util.LinkedHashMap[String, Val.Obj.Member]
for (s <- visitComp(e.first :: e.rest, Array(compScope))) {
lazy val newScope: ValScope = s.extend(newBindings, newSelf, null)

lazy val newBindings = visitBindings(binds, (self, sup) => newScope)

visitExpr(e.key)(s) match {
case Val.Str(_, k) =>
val prev_length = builder.size()
builder.put(
k,
new Val.Obj.Member(e.plus, Visibility.Normal) {
def invoke(self: Val.Obj, sup: Val.Obj, fs: FileScope, ev: EvalScope): Val =
visitExpr(e.value)(
s.extend(newBindings, self, null)
)
val builder = new java.util.LinkedHashMap[String, Val.Obj.Member]
for (s <- visitComp(e.first :: e.rest, Array(compScope))) {
visitExpr(e.key)(s) match {
case Val.Str(_, k) =>
val prev_length = builder.size()
builder.put(
k,
new Val.Obj.Member(e.plus, Visibility.Normal) {
def invoke(self: Val.Obj, sup: Val.Obj, fs: FileScope, ev: EvalScope): Val = {
lazy val newScope: ValScope = s.extend(newBindings, self, sup)
Copy link
Contributor Author

@JoshRosen JoshRosen May 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two changes here:

  1. use self instead of newSelf, which allows for bindings' references to self to be properly updated when re-evaluated.
  2. Pass in sup to allow super references to resolve properly (in both local variables and in the values of the resulting object).

lazy val newBindings = visitBindings(binds, (self, sup) => newScope)
visitExpr(e.value)(
s.extend(newBindings, self, null)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On closer look, I see that this is still passing null for newSuper here and that probably indicates that there's still a latent bug. Let me see if I can devise a test case.

Copy link
Contributor Author

@JoshRosen JoshRosen May 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, there was an issue:

)
}
)
if (prev_length == builder.size() && settings.noDuplicateKeysInComprehension) {
Error.fail(s"Duplicate key ${k} in evaluated object comprehension.", e.pos);
}
case Val.Null(_) => // do nothing
case _ =>
}
}
val valueCache = if (sup == null) {
Val.Obj.getEmptyValueCacheForObjWithoutSuper(builder.size())
} else {
new java.util.HashMap[Any, Val]()
)
if (prev_length == builder.size() && settings.noDuplicateKeysInComprehension) {
Error.fail(s"Duplicate key ${k} in evaluated object comprehension.", e.pos);
}
case Val.Null(_) => // do nothing
case x =>
Error.fail(
s"Field name must be string or null, not ${x.prettyName}",
e.pos
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field name must be string or null, got ${x.prettyName}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I copied this wording from

case x =>
Error.fail(
s"Field name must be string or null, not ${x.prettyName}",
pos
)
further up in this file; I suppose I can factor it out into a helper function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I factored this out in bd4e1b2 but, curiously, this appears to cause a test failure in the JDK 17 build: https://github.com/databricks/sjsonnet/actions/runs/14891478275/job/41824335648 :

java.lang.StackOverflowError
    java.lang.String.rangeCheck(String.java:304)
    java.lang.String.<init>(String.java:300)
    jdk.internal.org.objectweb.asm.ClassReader.readUtf(ClassReader.java:3717)
    jdk.internal.org.objectweb.asm.ClassReader.readUtf(ClassReader.java:3685)
    jdk.internal.org.objectweb.asm.ClassReader.readUTF8(ClassReader.java:3666)
    jdk.internal.org.objectweb.asm.ClassReader.readConst(ClassReader.java:3841)
    java.lang.invoke.MethodHandles$Lookup$ClassFile.newInstance(MethodHandles.java:2258)
    java.lang.invoke.MethodHandles$Lookup.makeHiddenClassDefiner(MethodHandles.java:2359)
    java.lang.invoke.MethodHandles$Lookup.defineHiddenClass(MethodHandles.java:2127)
    java.lang.invoke.InnerClassLambdaMetafactory.generateInnerClass(InnerClassLambdaMetafactory.java:407)
    java.lang.invoke.InnerClassLambdaMetafactory.spinInnerClass(InnerClassLambdaMetafactory.java:315)
    java.lang.invoke.InnerClassLambdaMetafactory.buildCallSite(InnerClassLambdaMetafactory.java:228)
    java.lang.invoke.LambdaMetafactory.altMetafactory(LambdaMetafactory.java:536)
    java.lang.invoke.BootstrapMethodInvoker.invoke(BootstrapMethodInvoker.java:147)
    java.lang.invoke.CallSite.makeSite(CallSite.java:315)
    java.lang.invoke.MethodHandleNatives.linkCallSiteImpl(MethodHandleNatives.java:281)
    java.lang.invoke.MethodHandleNatives.linkCallSite(MethodHandleNatives.java:271)
    os.Path.relativeTo(Path.scala:584)
    sjsonnet.OsPath.relativeToString(OsPath.scala:6)
    sjsonnet.Error$Frame.<init>(Error.scala:51)
    sjsonnet.Error$.fail(Error.scala:71)
    sjsonnet.Materializer.apply0(Materializer.scala:72)
    sjsonnet.Materializer.apply0(Materializer.scala:53)
    sjsonnet.Materializer.apply0(Materializer.scala:53)
[...]

I think the problem is that we have code within the Materializer which catches StackOverflowError and then tries to call Error.fail and that, in turn, hits a secondary StackOverflow because the stack is already so deep:

} catch {
case _: StackOverflowError =>
Error.fail("Stackoverflow while materializing, possibly due to recursive value", v.pos)
}

I tried adding an @inline to the new helper to see if that would somehow hack around this (f5e40c2) but it didn't help.

This preexisting design seems very fragile.

If we want to more fundamentally fix this and remove the core fragility, I think we probably need to allow the StackOverflowError to bubble higher rather than catching it so deep, but that introduces some new challenges around making sure that we preserve the v which triggered the error. I'm thinking that we might want to re-throw a wrapped exception containing a pointer to v and then catch it and transform it to an Error.fail at a higher level of the callstack (e.g. the outermost apply0 call). That's a larger refactoring / change, though, and I don't want to co-mingle it here.

Given this, I'm going to back out of the error message DRY and will re-attempt it in a separate PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I think this might just be nondeterministically broken because the current reverted commit fails but its checkout tree is identical to a commit which passed two days ago.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that this is a test ordering issue: the current test for the stack overflow handling fails if it runs prior to other tests: it was implicitly relying on other tests to perform certain loading and initialization steps.

We should fix this properly (e.g. by hardening the actual implementation, as this is a real bug), but as a temporary "unblock the test flakiness" hack I'm trying to force a particular loading step: 5cd4ed8

Copy link
Contributor

@He-Pin He-Pin May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@inline is just a hint; we can't rely on it.
For StackOverflow, I raised #284 once, but it may hurt some performance; there is some TCO in other implementations, eg : google/jsonnet#1142

)
}
new Val.Obj(e.pos, builder, false, null, sup, valueCache)
}

newSelf
val valueCache = if (sup == null) {
Val.Obj.getEmptyValueCacheForObjWithoutSuper(builder.size())
} else {
new java.util.HashMap[Any, Val]()
}
new Val.Obj(e.pos, builder, false, null, sup, valueCache)
}

@tailrec
Expand Down
52 changes: 52 additions & 0 deletions sjsonnet/test/src/sjsonnet/EvaluatorTests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,58 @@ object EvaluatorTests extends TestSuite {
"""{local y = $["2"], [x]: if x == "1" then y else 0, for x in ["1", "2"]}["1"]""",
useNewEvaluator = useNewEvaluator
) ==> ujson.Num(0)
// References between locals in an object comprehension:
eval(
"""{local a = 1, local b = a + 1, [k]: b + 1 for k in ["x"]}""",
useNewEvaluator = useNewEvaluator
) ==> ujson.Obj("x" -> ujson.Num(3))
// Locals which reference variables from the comprehension:
eval(
"""{local x2 = k*2, [std.toString(k)]: x2 for k in [1]}"""
) ==> ujson.Obj("1" -> ujson.Num(2))
// Regression test for https://github.com/databricks/sjsonnet/issues/357
// self references in object comprehension locals are properly rebound during inheritance:
eval(
"""
|local lib = {
| foo()::
| {
| local global = self,
|
| [iterParam]: global.base {
| foo: iterParam
| }
| for iterParam in ["foo"]
| },
|};
|
|{
| base:: {}
|}
|+ lib.foo()
|""".stripMargin,
useNewEvaluator = useNewEvaluator
) ==> ujson.Obj("foo" -> ujson.Obj("foo" -> "foo"))
// Regression test for a related bug involving local references to `super`:
eval(
"""
|local lib = {
| foo():: {
| local sx = super.x,
| [k]: sx + 1
| for k in ["x"]
| },
|};
|
|{ x: 2 }
|+ lib.foo()
|""".stripMargin,
useNewEvaluator = useNewEvaluator
) ==> ujson.Obj("x" -> ujson.Num(3))
// Regression test for a bug in handling of non-string field names:
evalErr("{[k]: k for k in [1]}", useNewEvaluator = useNewEvaluator) ==>
"""sjsonnet.Error: Field name must be string or null, not number
|at .(:1:2)""".stripMargin
}
test("super") {
test("implicit") {
Expand Down