wasm generation bug workaround, docs and debugging tips

This commit is contained in:
Sergey Chernov 2026-01-24 18:10:49 +03:00
parent 7b1ba71ef0
commit 717a79aca2
5 changed files with 514 additions and 788 deletions

View File

@ -0,0 +1,15 @@
# AI notes: avoid Kotlin/Wasm invalid IR with suspend lambdas
## Do
- Prefer explicit `object : Statement()` with `override suspend fun execute(...)` when building compiler statements.
- Keep `Statement` objects non-lambda, especially in compiler hot paths like parsing/var declarations.
- If you need conditional behavior, return early in `execute` instead of wrapping `parseExpression()` with `statement(...) { ... }`.
- When wasmJs tests hang in the browser, first check `wasmJsNodeTest` for a compile error; hangs often mean module instantiation failed.
## Don't
- Do not create suspend lambdas inside `Statement` factories (`statement { ... }`) for wasm targets.
- Do not "fix" hangs by increasing browser timeouts; it masks invalid wasm generation.
## Debugging tips
- Look for `$invokeCOROUTINE$` in wasm function names when mapping failures.
- If node test logs a wasm compile error, the browser hang is likely the same root cause.

View File

@ -0,0 +1,27 @@
# Wasm generation hang in wasmJs browser tests
## Summary
The wasmJs browser test runner hung after commit 5f819dc. The root cause was invalid WebAssembly generated by the Kotlin/Wasm backend when certain compiler paths emitted suspend lambdas for `Statement` execution. The invalid module failed to instantiate in the browser, and Karma kept the browser connected but never ran tests.
## Symptoms
- `:lynglib:wasmJsBrowserTest` hangs indefinitely in ChromeHeadless.
- `:lynglib:wasmJsNodeTest` fails with a WebAssembly compile error similar to:
- `struct.set expected type (ref null XXXX), found global.get of type (ref null YYYY)`
- The failing function name in the wasm name section looks like:
- `net.sergeych.lyng.$invokeCOROUTINE$.doResume`
## Root cause
The delegation/var-declaration changes introduced compiler-generated suspend lambdas inside `Statement` construction (e.g., `statement { ... }` wrappers). Kotlin/Wasm generates extra coroutine state for those suspend lambdas, which in this case produced invalid wasm IR (mismatched GC reference types). The browser loader then waits forever because the module fails to instantiate.
## Fix
Avoid suspend-lambda `Statement` construction in compiler code paths. Replace `statement { ... }` and other anonymous suspend lambdas with explicit `object : Statement()` implementations and move logic into `override suspend fun execute(...)`. This keeps the resulting wasm IR valid while preserving behavior.
## Where it was fixed
- `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt`
- `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt`
## Verification
- `./gradlew :lynglib:wasmJsNodeTest --info`
- `./gradlew :lynglib:wasmJsBrowserTest --info`
Both tests finish quickly after the change.

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -501,9 +501,8 @@ open class Obj {
if (obj.type == ObjRecord.Type.Delegated) { if (obj.type == ObjRecord.Type.Delegated) {
val del = obj.delegate ?: scope.raiseError("Internal error: delegated property $name has no delegate") val del = obj.delegate ?: scope.raiseError("Internal error: delegated property $name has no delegate")
val th = if (this === ObjVoid) ObjNull else this val th = if (this === ObjVoid) ObjNull else this
val res = del.invokeInstanceMethod(scope, "getValue", Arguments(th, ObjString(name)), onNotFoundResult = { if (del.objClass.getInstanceMemberOrNull("getValue") == null) {
// If getValue not found, return a wrapper that calls invoke val wrapper = object : Statement() {
object : Statement() {
override val pos: Pos = Pos.builtIn override val pos: Pos = Pos.builtIn
override suspend fun execute(s: Scope): Obj { override suspend fun execute(s: Scope): Obj {
val th2 = if (s.thisObj === ObjVoid) ObjNull else s.thisObj val th2 = if (s.thisObj === ObjVoid) ObjNull else s.thisObj
@ -511,7 +510,12 @@ open class Obj {
return del.invokeInstanceMethod(s, "invoke", Arguments(*allArgs)) return del.invokeInstanceMethod(s, "invoke", Arguments(*allArgs))
} }
} }
}) return obj.copy(
value = wrapper,
type = ObjRecord.Type.Other
)
}
val res = del.invokeInstanceMethod(scope, "getValue", Arguments(th, ObjString(name)))
return obj.copy( return obj.copy(
value = res, value = res,
type = ObjRecord.Type.Other type = ObjRecord.Type.Other
@ -605,16 +609,17 @@ open class Obj {
scope.raiseNotImplemented() scope.raiseNotImplemented()
} }
suspend fun invoke(scope: Scope, thisObj: Obj, args: Arguments, declaringClass: ObjClass? = null): Obj = suspend fun invoke(scope: Scope, thisObj: Obj, args: Arguments, declaringClass: ObjClass? = null): Obj {
if (PerfFlags.SCOPE_POOL) if (PerfFlags.SCOPE_POOL) {
scope.withChildFrame(args, newThisObj = thisObj) { child -> return scope.withChildFrame(args, newThisObj = thisObj) { child ->
if (declaringClass != null) child.currentClassCtx = declaringClass if (declaringClass != null) child.currentClassCtx = declaringClass
callOn(child) callOn(child)
} }
else }
callOn(scope.createChildScope(scope.pos, args = args, newThisObj = thisObj).also { val child = scope.createChildScope(scope.pos, args = args, newThisObj = thisObj)
if (declaringClass != null) it.currentClassCtx = declaringClass if (declaringClass != null) child.currentClassCtx = declaringClass
}) return callOn(child)
}
suspend fun invoke(scope: Scope, thisObj: Obj, vararg args: Obj): Obj = suspend fun invoke(scope: Scope, thisObj: Obj, vararg args: Obj): Obj =
callOn( callOn(