fix endless recursion in scope resolution in some specific cases

This commit is contained in:
sergeych committed 2025-12-09 23:55:50 +01:00
1 parent c0fab3d60e
commit bcabfc8962
7 files changed
+426 -46

No files matched your search

@@ -38,35 +38,80 @@ class ClosureScope(val callScope: Scope, val closureScope: Scope) :
}
override fun get(name: String): ObjRecord? {
// Fast-path built-ins
if (name == "this") return thisObj.asReadonly
// Priority:
// 1) Locals and arguments declared in this lambda frame (including values defined before suspension)
// 2) Instance/class members of the captured receiver (`closureScope.thisObj`), e.g., fields like `coll`, `factor`
// 3) Symbols from the captured closure scope (its locals and parents)
// 2) Instance/class members of the captured receiver (`closureScope.thisObj`)
// 3) Symbols from the captured closure scope chain (locals/parents), ignoring nested ClosureScope overrides
// 4) Instance members of the caller's `this` (e.g., FlowBuilder.emit)
// 5) Fallback to the standard chain (this frame -> parent (callScope) -> class members)
// 5) Symbols from the caller chain (locals/parents), ignoring nested ClosureScope overrides
// 6) Special fallback for module pseudo-symbols (e.g., __PACKAGE__)
// First, prefer locals/arguments bound in this frame
// 1) Locals/arguments in this closure frame
super.objects[name]?.let { return it }
super.localBindings[name]?.let { return it }
// Prefer instance fields/methods declared on the captured receiver:
// First, resolve real instance fields stored in the instance scope (constructor vars like `coll`, `factor`)
// 2) Members on the captured receiver instance
(closureScope.thisObj as? net.sergeych.lyng.obj.ObjInstance)
?.instanceScope
?.objects
?.get(name)
?.let { return it }
// Then, try class-declared members (methods/properties declared in the class body)
closureScope.thisObj.objClass.getInstanceMemberOrNull(name)?.let { return it }
// Then delegate to the full closure scope chain (locals, parents, etc.)
closureScope.get(name)?.let { return it }
// 3) Closure scope chain (locals/parents + members), ignore ClosureScope overrides to prevent recursion
closureScope.chainLookupWithMembers(name)?.let { return it }
// Allow resolving instance members of the caller's `this` (e.g., FlowBuilder.emit)
// 4) Caller `this` members
callScope.thisObj.objClass.getInstanceMemberOrNull(name)?.let { return it }
// Fallback to the standard lookup chain: this frame -> parent (callScope) -> class members
return super.get(name)
// 5) Caller chain (locals/parents + members)
callScope.chainLookupWithMembers(name)?.let { return it }
// 6) Module pseudo-symbols (e.g., __PACKAGE__) — walk caller ancestry and query ModuleScope directly
if (name.startsWith("__")) {
var s: Scope? = callScope
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
if (s is ModuleScope) return s.get(name)
s = s.parent
}
}
// 7) Direct module/global fallback: try to locate nearest ModuleScope and check its own locals
fun lookupInModuleAncestry(from: Scope): ObjRecord? {
var s: Scope? = from
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
if (s is ModuleScope) {
s.objects[name]?.let { return it }
s.localBindings[name]?.let { return it }
// check immediate parent (root scope) locals/constants for globals like `delay`
val p = s.parent
if (p != null) {
p.objects[name]?.let { return it }
p.localBindings[name]?.let { return it }
p.thisObj.objClass.getInstanceMemberOrNull(name)?.let { return it }
}
return null
}
s = s.parent
}
return null
}
lookupInModuleAncestry(closureScope)?.let { return it }
lookupInModuleAncestry(callScope)?.let { return it }
// 8) Global root scope constants/functions (e.g., delay, yield) via current import provider
runCatching { this.currentImportProvider.rootScope.objects[name] }.getOrNull()?.let { return it }
// Final safe fallback: base scope lookup from this frame walking raw parents
return baseGetIgnoreClosure(name)
}
}
@@ -62,6 +62,108 @@ open class Scope(
*/
internal val localBindings: MutableMap<String, ObjRecord> = mutableMapOf()
/** Debug helper: ensure assigning [candidateParent] does not create a structural cycle. */
private fun ensureNoCycle(candidateParent: Scope?) {
if (candidateParent == null) return
var s: Scope? = candidateParent
var hops = 0
while (s != null && hops++ < 1024) {
if (s === this) {
// In production we silently ignore; for debugging throw an error to signal misuse
throw IllegalStateException("cycle detected in scope parent chain assignment")
}
s = s.parent
}
}
/**
* Internal lookup helpers that deliberately avoid invoking overridden `get` implementations
* (notably in ClosureScope) to prevent accidental ping-pong and infinite recursion across
* intertwined closure frames. They traverse the plain parent chain and consult only locals
* and bindings of each frame. Instance/class member fallback must be decided by the caller.
*/
private fun tryGetLocalRecord(s: Scope, name: String): ObjRecord? {
s.objects[name]?.let { return it }
s.localBindings[name]?.let { return it }
s.getSlotIndexOf(name)?.let { return s.getSlotRecord(it) }
return null
}
internal fun chainLookupIgnoreClosure(name: String): ObjRecord? {
var s: Scope? = this
// use frameId to detect unexpected structural cycles in the parent chain
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) return null
tryGetLocalRecord(s, name)?.let { return it }
s = s.parent
}
return null
}
/**
* Perform base Scope.get semantics for this frame without delegating into parent.get
* virtual dispatch. This checks:
* - locals/bindings in this frame
* - walks raw parent chain for locals/bindings (ignoring ClosureScope-specific overrides)
* - finally falls back to this frame's `thisObj` instance/class members
*/
internal fun baseGetIgnoreClosure(name: String): ObjRecord? {
// 1) locals/bindings in this frame
tryGetLocalRecord(this, name)?.let { return it }
// 2) walk parents for plain locals/bindings only
var s = parent
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) return null
tryGetLocalRecord(s, name)?.let { return it }
s = s.parent
}
// 3) fallback to instance/class members of this frame's thisObj
return thisObj.objClass.getInstanceMemberOrNull(name)
}
/**
* Walk the ancestry starting from this scope and try to resolve [name] against:
* - locals/bindings of each frame
* - then instance/class members of each frame's `thisObj`.
* This completely avoids invoking overridden `get` implementations, preventing
* ping-pong recursion between `ClosureScope` frames.
*/
internal fun chainLookupWithMembers(name: String): ObjRecord? {
var s: Scope? = this
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) return null
tryGetLocalRecord(s, name)?.let { return it }
s.thisObj.objClass.getInstanceMemberOrNull(name)?.let { return it }
s = s.parent
}
return null
}
/**
* Create a non-pooled snapshot of this scope suitable for capturing as a closure environment.
* Copies locals, slots, and localBindings; preserves parent chain and class context.
*/
fun snapshotForClosure(): Scope {
val snap = Scope(parent, args, pos, thisObj)
snap.currentClassCtx = this.currentClassCtx
// copy locals and bindings
snap.objects.putAll(this.objects)
snap.localBindings.putAll(this.localBindings)
// copy slots map preserving indices
if (this.slotCount() > 0) {
var i = 0
while (i < this.slotCount()) {
val rec = this.getSlotRecord(i)
snap.allocateSlotFor(this.nameToSlot.entries.firstOrNull { it.value == i }?.key ?: "slot${'$'}i", rec)
i++
}
}
return snap
}
/**
* Hint internal collections to reduce reallocations for upcoming parameter/local assignments.
* Only effective for ArrayList-backed slots; maps are left as-is (rehashed lazily by JVM).
@@ -200,6 +302,7 @@ open class Scope(
* Clears locals and slots, assigns new frameId, and sets parent/args/pos/thisObj.
*/
fun resetForReuse(parent: Scope?, args: Arguments, pos: Pos, thisObj: Obj) {
ensureNoCycle(parent)
this.parent = parent
this.args = args
this.pos = pos
@@ -220,7 +323,10 @@ open class Scope(
* Creates a new child scope using the provided arguments and optional `thisObj`.
*/
fun createChildScope(pos: Pos, args: Arguments = Arguments.EMPTY, newThisObj: Obj? = null): Scope =
Scope(this, args, pos, newThisObj ?: thisObj).also { it.reserveLocalCapacity(args.list.size + 4) }
Scope(this, args, pos, newThisObj ?: thisObj).also {
it.ensureNoCycle(it.parent)
it.reserveLocalCapacity(args.list.size + 4)
}
/**
* Execute a block inside a child frame. Guarded for future pooling via [PerfFlags.SCOPE_POOL].
@@ -249,7 +355,7 @@ open class Scope(
* @return A new instance of [Scope] initialized with the specified arguments and `thisObj`.
*/
fun createChildScope(args: Arguments = Arguments.EMPTY, newThisObj: Obj? = null): Scope =
Scope(this, args, pos, newThisObj ?: thisObj)
Scope(this, args, pos, newThisObj ?: thisObj).also { it.ensureNoCycle(it.parent) }
/**
* @return A child scope with the same arguments, position and [thisObj]
@@ -18,6 +18,7 @@
package net.sergeych.lyng.obj
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.ClosureScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Statement
@@ -54,13 +55,16 @@ class ObjDynamicContext(val delegate: ObjDynamic) : Obj() {
open class ObjDynamic(var readCallback: Statement? = null, var writeCallback: Statement? = null) : Obj() {
override val objClass: ObjClass = type
// Capture the lexical scope used to build this dynamic so callbacks can see outer locals
internal var builderScope: Scope? = null
/**
* Use read callback to dynamically resolve the field name. Note that it does not work
* with method invocation which is implemented separately in [invokeInstanceMethod] below.
*/
override suspend fun readField(scope: Scope, name: String): ObjRecord {
return readCallback?.execute(scope.createChildScope(Arguments(ObjString(name))))?.let {
val execBase = builderScope?.let { ClosureScope(scope, it) } ?: scope
return readCallback?.execute(execBase.createChildScope(Arguments(ObjString(name))))?.let {
if (writeCallback != null)
it.asMutable
else
@@ -79,28 +83,27 @@ open class ObjDynamic(var readCallback: Statement? = null, var writeCallback: St
args: Arguments,
onNotFoundResult: (() -> Obj?)?
): Obj {
val over = readCallback?.execute(
scope.createChildScope(
Arguments(ObjString(name)
)
)
)
val execBase = builderScope?.let { ClosureScope(scope, it) } ?: scope
val over = readCallback?.execute(execBase.createChildScope(Arguments(ObjString(name))))
return over?.invoke(scope, scope.thisObj, args)
?: super.invokeInstanceMethod(scope, name, args, onNotFoundResult)
}
override suspend fun writeField(scope: Scope, name: String, newValue: Obj) {
writeCallback?.execute(scope.createChildScope(Arguments(ObjString(name), newValue)))
val execBase = builderScope?.let { ClosureScope(scope, it) } ?: scope
writeCallback?.execute(execBase.createChildScope(Arguments(ObjString(name), newValue)))
?: super.writeField(scope, name, newValue)
}
override suspend fun getAt(scope: Scope, index: Obj): Obj {
return readCallback?.execute(scope.createChildScope(Arguments(index)))
val execBase = builderScope?.let { ClosureScope(scope, it) } ?: scope
return readCallback?.execute(execBase.createChildScope(Arguments(index)))
?: super.getAt(scope, index)
}
override suspend fun putAt(scope: Scope, index: Obj, newValue: Obj) {
writeCallback?.execute(scope.createChildScope(Arguments(index, newValue)))
val execBase = builderScope?.let { ClosureScope(scope, it) } ?: scope
writeCallback?.execute(execBase.createChildScope(Arguments(index, newValue)))
?: super.putAt(scope, index, newValue)
}
@@ -109,7 +112,12 @@ open class ObjDynamic(var readCallback: Statement? = null, var writeCallback: St
suspend fun create(scope: Scope, builder: Statement): ObjDynamic {
val delegate = ObjDynamic()
val context = ObjDynamicContext(delegate)
builder.execute(scope.createChildScope(newThisObj = context))
// Capture the function's lexical scope (scope) so callbacks can see outer locals like parameters.
// Build the dynamic in a child scope purely to set `this` to context, but keep captured closure at parent.
val buildScope = scope.createChildScope(newThisObj = context)
// Snapshot the caller scope to capture locals/args even if the runtime pools/reuses frames
delegate.builderScope = scope.snapshotForClosure()
builder.execute(buildScope)
return delegate
}
@@ -198,6 +198,10 @@ open class ObjException(
)) {
scope.addConst(name, getOrCreateExceptionClass(name))
}
// Backward compatibility alias used in older tests/docs
val snd = getOrCreateExceptionClass("SymbolNotDefinedException")
scope.addConst("SymbolNotFound", snd)
existingErrorClasses["SymbolNotFound"] = snd
}
}
}
@@ -1240,7 +1240,28 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.localVarPicMiss++
// 2) Fallback to current-scope object or field on `this`
scope[name]?.let { return it }
return scope.thisObj.readField(scope, name)
// 2a) Try nearest ClosureScope's closure ancestry explicitly
run {
var s: Scope? = scope
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
if (s is ClosureScope) {
s.closureScope.chainLookupWithMembers(name)?.let { return it }
}
s = s.parent
}
}
// 2b) Try raw ancestry local/binding lookup (cycle-safe), including slots in parents
scope.chainLookupIgnoreClosure(name)?.let { return it }
try {
return scope.thisObj.readField(scope, name)
} catch (e: ExecutionError) {
// Map missing symbol during unqualified lookup to SymbolNotFound (SymbolNotDefinedException)
// to preserve legacy behavior expected by tests.
if ((e.message ?: "").contains("no such field: $name")) scope.raiseSymbolNotFound(name)
throw e
}
}
val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount())
val slot = if (hit) cachedSlot else resolveSlot(scope)
@@ -1253,7 +1274,24 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.localVarPicMiss++
// 2) Fallback name in scope or field on `this`
scope[name]?.let { return it }
return scope.thisObj.readField(scope, name)
run {
var s: Scope? = scope
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
if (s is ClosureScope) {
s.closureScope.chainLookupWithMembers(name)?.let { return it }
}
s = s.parent
}
}
scope.chainLookupIgnoreClosure(name)?.let { return it }
try {
return scope.thisObj.readField(scope, name)
} catch (e: ExecutionError) {
if ((e.message ?: "").contains("no such field: $name")) scope.raiseSymbolNotFound(name)
throw e
}
}
override suspend fun evalValue(scope: Scope): Obj {
@@ -1262,14 +1300,48 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
scope.getSlotIndexOf(name)?.let { return scope.getSlotRecord(it).value }
// fallback to current-scope object or field on `this`
scope[name]?.let { return it.value }
return scope.thisObj.readField(scope, name).value
run {
var s: Scope? = scope
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
if (s is ClosureScope) {
s.closureScope.chainLookupWithMembers(name)?.let { return it.value }
}
s = s.parent
}
}
scope.chainLookupIgnoreClosure(name)?.let { return it.value }
return try {
scope.thisObj.readField(scope, name).value
} catch (e: ExecutionError) {
if ((e.message ?: "").contains("no such field: $name")) scope.raiseSymbolNotFound(name)
throw e
}
}
val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount())
val slot = if (hit) cachedSlot else resolveSlot(scope)
if (slot >= 0) return scope.getSlotRecord(slot).value
// Fallback name in scope or field on `this`
scope[name]?.let { return it.value }
return scope.thisObj.readField(scope, name).value
run {
var s: Scope? = scope
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
if (s is ClosureScope) {
s.closureScope.chainLookupWithMembers(name)?.let { return it.value }
}
s = s.parent
}
}
scope.chainLookupIgnoreClosure(name)?.let { return it.value }
return try {
scope.thisObj.readField(scope, name).value
} catch (e: ExecutionError) {
if ((e.message ?: "").contains("no such field: $name")) scope.raiseSymbolNotFound(name)
throw e
}
}
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
@@ -1286,6 +1358,26 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
else scope.raiseError("Cannot assign to immutable value")
return
}
run {
var s: Scope? = scope
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
if (s is ClosureScope) {
s.closureScope.chainLookupWithMembers(name)?.let { stored ->
if (stored.isMutable) stored.value = newValue
else scope.raiseError("Cannot assign to immutable value")
return
}
}
s = s.parent
}
}
scope.chainLookupIgnoreClosure(name)?.let { stored ->
if (stored.isMutable) stored.value = newValue
else scope.raiseError("Cannot assign to immutable value")
return
}
// Fallback: write to field on `this`
scope.thisObj.writeField(scope, name, newValue)
return
@@ -1302,6 +1394,26 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef {
else scope.raiseError("Cannot assign to immutable value")
return
}
run {
var s: Scope? = scope
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
if (s is ClosureScope) {
s.closureScope.chainLookupWithMembers(name)?.let { stored ->
if (stored.isMutable) stored.value = newValue
else scope.raiseError("Cannot assign to immutable value")
return
}
}
s = s.parent
}
}
scope.chainLookupIgnoreClosure(name)?.let { stored ->
if (stored.isMutable) stored.value = newValue
else scope.raiseError("Cannot assign to immutable value")
return
}
scope.thisObj.writeField(scope, name, newValue)
return
}