fix endless recursion in scope resolution in some specific cases

This commit is contained in:
Sergey Chernov 2025-12-09 23:55:50 +01:00
parent c0fab3d60e
commit bcabfc8962
7 changed files with 426 additions and 46 deletions

View File

@ -117,25 +117,30 @@ kotlin {
}
// ---- Build-time generation of stdlib text from .lyng files into a Kotlin constant ----
// The .lyng source of the stdlib lives here (module-relative path):
val lyngStdlibDir = layout.projectDirectory.dir("stdlib/lyng")
// The generated Kotlin source will be placed here and added to commonMain sources:
val generatedLyngStdlibDir = layout.buildDirectory.dir("generated/source/lyngStdlib/commonMain/kotlin")
// Implemented as a proper task type compatible with Gradle Configuration Cache
val generateLyngStdlib by tasks.registering {
group = "build"
description = "Generate Kotlin source with embedded lyng stdlib text"
inputs.dir(lyngStdlibDir)
outputs.dir(generatedLyngStdlibDir)
// Simpler: opt out of configuration cache for this ad-hoc generator task
notCompatibleWithConfigurationCache("Uses dynamic file IO in doLast; trivial generator")
abstract class GenerateLyngStdlib : DefaultTask() {
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val sourceDir: DirectoryProperty
doLast {
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
@TaskAction
fun generate() {
val targetPkg = "net.sergeych.lyng.stdlib_included"
val targetDir = generatedLyngStdlibDir.get().asFile.resolve(targetPkg.replace('.', '/'))
val pkgPath = targetPkg.replace('.', '/')
val outBase = outputDir.get().asFile
val targetDir = outBase.resolve(pkgPath)
targetDir.mkdirs()
val files = lyngStdlibDir.asFileTree.matching { include("**/*.lyng") }.files.sortedBy { it.name }
val srcDir = sourceDir.get().asFile
val files = srcDir.walkTopDown()
.filter { it.isFile && it.extension == "lyng" }
.sortedBy { it.name }
.toList()
val content = if (files.isEmpty()) "" else buildString {
files.forEachIndexed { idx, f ->
val text = f.readText()
@ -144,13 +149,12 @@ val generateLyngStdlib by tasks.registering {
}
}
// Emit as a regular quoted Kotlin string to avoid triple-quote edge cases
fun escapeForQuoted(s: String): String = buildString {
for (ch in s) when (ch) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> {} // drop CR
'\r' -> {}
'\t' -> append("\\t")
else -> append(ch)
}
@ -168,6 +172,18 @@ val generateLyngStdlib by tasks.registering {
}
}
// The .lyng source of the stdlib lives here (module-relative path):
val lyngStdlibDir = layout.projectDirectory.dir("stdlib/lyng")
// The generated Kotlin source will be placed here and added to commonMain sources:
val generatedLyngStdlibDir = layout.buildDirectory.dir("generated/source/lyngStdlib/commonMain/kotlin")
val generateLyngStdlib by tasks.registering(GenerateLyngStdlib::class) {
group = "build"
description = "Generate Kotlin source with embedded lyng stdlib text"
sourceDir.set(lyngStdlibDir)
outputDir.set(generatedLyngStdlibDir)
}
// Add the generated directory to commonMain sources
kotlin.sourceSets.named("commonMain") {
kotlin.srcDir(generatedLyngStdlibDir)

View File

@ -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)
}
}

View File

@ -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]

View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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
}

View File

@ -61,6 +61,74 @@ class ScriptTest {
println("version = ${LyngVersion}")
}
@Test
fun testClosureSeesCallerLocalsInLaunch() = runTest {
val scope = Script.newScope()
val res = scope.eval(
"""
var counter = 0
val d = launch {
val c = counter
delay(1)
counter = c + 1
}
d.await()
counter
""".trimIndent()
)
assertEquals(1L, (res as ObjInt).value)
}
@Test
fun testClosureResolvesGlobalsInLaunch() = runTest {
val scope = Script.newScope()
val res = scope.eval(
"""
val d = launch {
delay(1)
yield()
}
d.await()
42
""".trimIndent()
)
assertEquals(42L, (res as ObjInt).value)
}
@Test
fun testClosureSeesModulePseudoSymbol() = runTest {
val scope = Script.newScope()
val res = scope.eval(
"""
val s = { __PACKAGE__ }
s()
""".trimIndent()
)
// __PACKAGE__ is a string; just ensure it's a string and non-empty
assertTrue(res is ObjString && res.value.isNotEmpty())
}
@Test
fun testNoInfiniteRecursionOnUnknownInNestedClosure() = runTest {
val scope = Script.newScope()
withTimeout(1.seconds) {
// Access an unknown symbol inside nested closures; should throw quickly, not hang
try {
scope.eval(
"""
val f = { { unknown_symbol_just_for_test } }
f()()
""".trimIndent()
)
fail("Expected exception not thrown")
} catch (_: ExecutionError) {
// ok
} catch (_: ScriptError) {
// ok
}
}
}
// --- Helpers to test iterator cancellation semantics ---
class ObjTestIterable : Obj() {
@ -4070,4 +4138,25 @@ class ScriptTest {
""")
}
@Test
fun testHangOnNonexistingMethod() = runTest {
eval("""
class T(someList) {
fun f() {
nonExistingMethod()
}
}
val t = T([1,2])
try {
for( i in 1..10 ) {
t.f()
}
}
catch(t: SymbolNotFound) {
println(t::class)
// ok
}
""")
}
}