From bcabfc8962b8a74a025b053bde1ca7ef02dd85eb Mon Sep 17 00:00:00 2001 From: sergeych Date: Tue, 9 Dec 2025 23:55:50 +0100 Subject: [PATCH] fix endless recursion in scope resolution in some specific cases --- lynglib/build.gradle.kts | 48 ++++--- .../kotlin/net/sergeych/lyng/ClosureScope.kt | 71 +++++++++-- .../kotlin/net/sergeych/lyng/Scope.kt | 110 +++++++++++++++- .../net/sergeych/lyng/obj/ObjDynamic.kt | 30 +++-- .../net/sergeych/lyng/obj/ObjException.kt | 4 + .../kotlin/net/sergeych/lyng/obj/ObjRef.kt | 120 +++++++++++++++++- lynglib/src/commonTest/kotlin/ScriptTest.kt | 89 +++++++++++++ 7 files changed, 426 insertions(+), 46 deletions(-) diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index 43d938e..71b9f39 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -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) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClosureScope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClosureScope.kt index a6e7447..6d0a5f0 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClosureScope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClosureScope.kt @@ -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(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(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) } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt index 7439d55..7bba7c0 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt @@ -62,6 +62,108 @@ open class Scope( */ internal val localBindings: MutableMap = 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(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(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(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] diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDynamic.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDynamic.kt index b2498f0..cd5baf3 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDynamic.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjDynamic.kt @@ -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 } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt index d122119..80a2c7f 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt @@ -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 } } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt index def70a6..8bdbf83 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt @@ -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(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(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(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(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(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(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 } diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 72745e5..8a37827 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -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 + } + """) + } + }