fix wasmJs suspend-lambda generation and add agent guidance

This commit is contained in:
Sergey Chernov 2026-01-24 18:59:58 +03:00
parent 717a79aca2
commit 062f9e7866
5 changed files with 771 additions and 408 deletions

8
AGENTS.md Normal file
View File

@ -0,0 +1,8 @@
# AI Agent Notes
## Kotlin/Wasm generation guardrails
- Avoid creating suspend lambdas for compiler runtime statements. Prefer explicit `object : Statement()` with `override suspend fun execute(...)`.
- Do not use `statement { ... }` or other inline suspend lambdas in compiler hot paths (e.g., parsing/var declarations, initializer thunks).
- If you need a wrapper for delegated properties, check for `getValue` explicitly and return a concrete `Statement` object when missing; avoid `onNotFoundResult` lambdas.
- If wasmJs browser tests hang, first run `:lynglib:wasmJsNodeTest` and look for wasm compilation errors; hangs usually mean module instantiation failed.
- Do not increase test timeouts to mask wasm generation errors; fix the invalid IR instead.

View File

@ -45,6 +45,7 @@ fun swapEnds(first, args..., last, f) {
- [Samples directory](docs/samples)
- [Formatter (core + CLI + IDE)](docs/formatter.md)
- [Books directory](docs)
- [AI agent guidance](AGENTS.md)
## Integration in Kotlin multiplatform

File diff suppressed because it is too large Load Diff

View File

@ -70,9 +70,8 @@ open class Scope(
internal fun findExtension(receiverClass: ObjClass, name: String): ObjRecord? {
var s: Scope? = this
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
var hops = 0
while (s != null && hops++ < 1024) {
// Proximity rule: check all extensions in the current scope before going to parent.
// Priority within scope: more specific class in MRO wins.
for (cls in receiverClass.mro) {
@ -106,28 +105,38 @@ open class Scope(
* 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, caller: net.sergeych.lyng.obj.ObjClass?): ObjRecord? {
internal fun tryGetLocalRecord(s: Scope, name: String, caller: net.sergeych.lyng.obj.ObjClass?): ObjRecord? {
caller?.let { ctx ->
s.objects[ctx.mangledName(name)]?.let { rec ->
if (rec.visibility == Visibility.Private) return rec
}
}
s.objects[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller, name)) return rec
}
caller?.let { ctx ->
s.localBindings[ctx.mangledName(name)]?.let { rec ->
if (rec.visibility == Visibility.Private) return rec
}
}
s.localBindings[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller, name)) return rec
}
s.getSlotIndexOf(name)?.let { idx ->
val rec = s.getSlotRecord(idx)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller, name)) return rec
}
return null
}
internal fun chainLookupIgnoreClosure(name: String): ObjRecord? {
internal fun chainLookupIgnoreClosure(name: String, followClosure: Boolean = false, caller: net.sergeych.lyng.obj.ObjClass? = null): 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, currentClassCtx)?.let { return it }
s = s.parent
// use hop counter to detect unexpected structural cycles in the parent chain
var hops = 0
val effectiveCaller = caller ?: currentClassCtx
while (s != null && hops++ < 1024) {
tryGetLocalRecord(s, name, effectiveCaller)?.let { return it }
s = if (followClosure && s is ClosureScope) s.closureScope else s.parent
}
return null
}
@ -144,9 +153,8 @@ open class Scope(
tryGetLocalRecord(this, name, currentClassCtx)?.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
var hops = 0
while (s != null && hops++ < 1024) {
tryGetLocalRecord(s, name, currentClassCtx)?.let { return it }
s = s.parent
}
@ -155,7 +163,7 @@ open class Scope(
this.extensions[cls]?.get(name)?.let { return it }
}
return thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) {
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx, name)) {
if (rec.type == ObjRecord.Type.Field || rec.type == ObjRecord.Type.Property || rec.isAbstract) null
else rec
} else null
@ -169,23 +177,22 @@ open class Scope(
* This completely avoids invoking overridden `get` implementations, preventing
* ping-pong recursion between `ClosureScope` frames.
*/
internal fun chainLookupWithMembers(name: String, caller: net.sergeych.lyng.obj.ObjClass? = currentClassCtx): ObjRecord? {
internal fun chainLookupWithMembers(name: String, caller: net.sergeych.lyng.obj.ObjClass? = currentClassCtx, followClosure: Boolean = false): ObjRecord? {
var s: Scope? = this
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) return null
var hops = 0
while (s != null && hops++ < 1024) {
tryGetLocalRecord(s, name, caller)?.let { return it }
for (cls in s.thisObj.objClass.mro) {
s.extensions[cls]?.get(name)?.let { return it }
}
s.thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, caller)) {
if (canAccessMember(rec.visibility, rec.declaringClass, caller, name)) {
if (rec.type == ObjRecord.Type.Field || rec.type == ObjRecord.Type.Property || rec.isAbstract) {
// ignore fields, properties and abstracts here, they will be handled by the caller via readField
} else return rec
}
}
s = s.parent
s = if (followClosure && s is ClosureScope) s.closureScope else s.parent
}
return null
}
@ -277,16 +284,22 @@ open class Scope(
raiseError(ObjSymbolNotDefinedException(this, "symbol is not defined: $name"))
fun raiseError(message: String): Nothing {
throw ExecutionError(ObjException(this, message))
val ex = ObjException(this, message)
throw ExecutionError(ex, pos, ex.message.value)
}
fun raiseError(obj: ObjException): Nothing {
throw ExecutionError(obj)
throw ExecutionError(obj, obj.scope.pos, obj.message.value)
}
fun raiseError(obj: Obj, pos: Pos, message: String): Nothing {
throw ExecutionError(obj, pos, message)
}
@Suppress("unused")
fun raiseNotFound(message: String = "not found"): Nothing {
throw ExecutionError(ObjNotFoundException(this, message))
val ex = ObjNotFoundException(this, message)
throw ExecutionError(ex, ex.scope.pos, ex.message.value)
}
inline fun <reified T : Obj> requiredArg(index: Int): T {
@ -314,37 +327,51 @@ open class Scope(
inline fun <reified T : Obj> thisAs(): T {
var s: Scope? = this
do {
val t = s!!.thisObj
while (s != null) {
val t = s.thisObj
if (t is T) return t
s = s.parent
} while (s != null)
}
raiseClassCastError("Cannot cast ${thisObj.objClass.className} to ${T::class.simpleName}")
}
internal val objects = mutableMapOf<String, ObjRecord>()
open operator fun get(name: String): ObjRecord? =
if (name == "this") thisObj.asReadonly
else {
// Prefer direct locals/bindings declared in this frame
(objects[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
open operator fun get(name: String): ObjRecord? {
if (name == "this") return thisObj.asReadonly
// 1. Prefer direct locals/bindings declared in this frame
tryGetLocalRecord(this, name, currentClassCtx)?.let { return it }
val p = parent
// 2. If we share thisObj with parent, delegate to parent to maintain
// "locals shadow members" priority across the this-context.
if (p != null && p.thisObj === thisObj) {
return p.get(name)
}
// Then, check known local bindings in this frame (helps after suspension)
?: localBindings[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
// 3. Otherwise, we are the "primary" scope for this thisObj (or have no parent),
// so check members of thisObj before walking up to a different this-context.
val receiver = thisObj
val effectiveClass = receiver as? ObjClass ?: receiver.objClass
for (cls in effectiveClass.mro) {
val rec = cls.members[name] ?: cls.classScope?.objects?.get(name)
if (rec != null && !rec.isAbstract) {
if (canAccessMember(rec.visibility, rec.declaringClass ?: cls, currentClassCtx, name)) {
return rec.copy(receiver = receiver)
}
// Walk up ancestry
?: parent?.get(name)
// Finally, fallback to class members on thisObj
?: thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) {
if (rec.type == ObjRecord.Type.Field || rec.type == ObjRecord.Type.Property || rec.isAbstract) null
else rec
} else null
}
)
}
// Finally, root object fallback
Obj.rootObjectType.members[name]?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx, name)) {
return rec.copy(receiver = receiver)
}
}
// 4. Finally, walk up ancestry to a scope with a different thisObj context
return p?.get(name)
}
// Slot fast-path API
@ -365,6 +392,20 @@ open class Scope(
nameToSlot[name]?.let { slots[it] = record }
}
/**
* Clear all references and maps to prevent memory leaks when pooled.
*/
fun scrub() {
this.parent = null
this.skipScopeCreation = false
this.currentClassCtx = null
objects.clear()
slots.clear()
nameToSlot.clear()
localBindings.clear()
extensions.clear()
}
/**
* Reset this scope instance so it can be safely reused as a fresh child frame.
* Clears locals and slots, assigns new frameId, and sets parent/args/pos/thisObj.
@ -374,6 +415,7 @@ open class Scope(
// that could interact badly with the new parent and produce a cycle.
this.parent = null
this.skipScopeCreation = false
this.currentClassCtx = parent?.currentClassCtx
// fresh identity for PIC caches
this.frameId = nextFrameId()
// clear locals and slot maps
@ -474,7 +516,8 @@ open class Scope(
declaringClass: net.sergeych.lyng.obj.ObjClass? = currentClassCtx,
isAbstract: Boolean = false,
isClosed: Boolean = false,
isOverride: Boolean = false
isOverride: Boolean = false,
isTransient: Boolean = false
): ObjRecord {
val rec = ObjRecord(
value, isMutable, visibility, writeVisibility,
@ -482,7 +525,8 @@ open class Scope(
type = recordType,
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride
isOverride = isOverride,
isTransient = isTransient
)
objects[name] = rec
// Index this binding within the current frame to help resolve locals across suspension
@ -501,12 +545,16 @@ open class Scope(
}
}
// Map to a slot for fast local access (ensure consistency)
val idx = getSlotIndexOf(name)
if (nameToSlot.isEmpty()) {
allocateSlotFor(name, rec)
} else {
val idx = nameToSlot[name]
if (idx == null) {
allocateSlotFor(name, rec)
} else {
slots[idx] = rec
}
}
return rec
}
@ -622,31 +670,34 @@ open class Scope(
}
suspend fun resolve(rec: ObjRecord, name: String): Obj {
if (rec.type == ObjRecord.Type.Delegated) {
val del = rec.delegate ?: raiseError("Internal error: delegated property $name has no delegate")
val th = if (thisObj === ObjVoid) ObjNull else thisObj
if (del.objClass.getInstanceMemberOrNull("getValue") == null) {
return object : Statement() {
override val pos: Pos = Pos.builtIn
override suspend fun execute(scope: Scope): Obj {
val th2 = if (scope.thisObj === ObjVoid) ObjNull else scope.thisObj
val allArgs = (listOf(th2, ObjString(name)) + scope.args.list).toTypedArray()
return del.invokeInstanceMethod(scope, "invoke", Arguments(*allArgs))
}
}
}
return del.invokeInstanceMethod(this, "getValue", Arguments(th, ObjString(name)))
}
return rec.value
val receiver = rec.receiver ?: thisObj
return receiver.resolveRecord(this, rec, name, rec.declaringClass).value
}
suspend fun assign(rec: ObjRecord, name: String, newValue: Obj) {
if (rec.type == ObjRecord.Type.Delegated) {
val del = rec.delegate ?: raiseError("Internal error: delegated property $name has no delegate")
val th = if (thisObj === ObjVoid) ObjNull else thisObj
val receiver = rec.receiver ?: thisObj
val del = rec.delegate ?: run {
if (receiver is ObjInstance) {
(receiver as ObjInstance).writeField(this, name, newValue)
return
}
raiseError("Internal error: delegated property $name has no delegate")
}
val th = if (receiver === ObjVoid) ObjNull else receiver
del.invokeInstanceMethod(this, "setValue", Arguments(th, ObjString(name), newValue))
return
}
if (rec.value is ObjProperty) {
(rec.value as ObjProperty).callSetter(this, rec.receiver ?: thisObj, newValue, rec.declaringClass)
return
}
// If it's a member (explicitly tracked by receiver or declaringClass), use writeField.
// Important: locals have receiver == null and declaringClass == null (enforced in addItem).
if (rec.receiver != null || (rec.declaringClass != null && (rec.type == ObjRecord.Type.Field || rec.type == ObjRecord.Type.Property))) {
(rec.receiver ?: thisObj).writeField(this, name, newValue)
return
}
if (!rec.isMutable && rec.value !== ObjUnset) raiseIllegalAssignment("can't reassign val $name")
rec.value = newValue
}

View File

@ -609,17 +609,16 @@ open class Obj {
scope.raiseNotImplemented()
}
suspend fun invoke(scope: Scope, thisObj: Obj, args: Arguments, declaringClass: ObjClass? = null): Obj {
if (PerfFlags.SCOPE_POOL) {
return scope.withChildFrame(args, newThisObj = thisObj) { child ->
suspend fun invoke(scope: Scope, thisObj: Obj, args: Arguments, declaringClass: ObjClass? = null): Obj =
if (PerfFlags.SCOPE_POOL)
scope.withChildFrame(args, newThisObj = thisObj) { child ->
if (declaringClass != null) child.currentClassCtx = declaringClass
callOn(child)
}
}
val child = scope.createChildScope(scope.pos, args = args, newThisObj = thisObj)
if (declaringClass != null) child.currentClassCtx = declaringClass
return callOn(child)
}
else
callOn(scope.createChildScope(scope.pos, args = args, newThisObj = thisObj).also {
if (declaringClass != null) it.currentClassCtx = declaringClass
})
suspend fun invoke(scope: Scope, thisObj: Obj, vararg args: Obj): Obj =
callOn(