wasm generation bug workaround, docs and debugging tips

This commit is contained in:
Sergey Chernov 2026-01-24 18:10:49 +03:00
parent 5f819dc87a
commit aec4a3766e
5 changed files with 161 additions and 95 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.

View File

@ -3053,7 +3053,7 @@ class Compiler(
cc.skipWsTokens()
cc.next() // consume '='
val expr = parseExpression() ?: throw ScriptError(cc.current().pos, "Expected getter expression")
(expr as? Statement) ?: statement(expr.pos) { s -> expr.execute(s) }
expr
} else {
throw ScriptError(cc.current().pos, "Expected { or = after get()")
}
@ -3074,7 +3074,7 @@ class Compiler(
cc.skipWsTokens()
cc.next() // consume '='
val expr = parseExpression() ?: throw ScriptError(cc.current().pos, "Expected setter expression")
val st = (expr as? Statement) ?: statement(expr.pos) { s -> expr.execute(s) }
val st = expr
statement(st.pos) { scope ->
val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument)
@ -3109,7 +3109,7 @@ class Compiler(
cc.current().pos,
"Expected setter expression"
)
val st = (expr as? Statement) ?: statement(expr.pos) { s -> expr.execute(s) }
val st = expr
statement(st.pos) { scope ->
val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument)
@ -3141,14 +3141,23 @@ class Compiler(
}
}
return statement(start) { context ->
return object : Statement() {
override val pos: Pos = start
override suspend fun execute(context: Scope): Obj {
if (extTypeName != null) {
val prop = if (getter != null || setter != null) {
ObjProperty(name, getter, setter)
} else {
// Simple val extension with initializer
val initExpr = initialExpression ?: throw ScriptError(start, "Extension val must be initialized")
ObjProperty(name, statement(initExpr.pos) { scp -> initExpr.execute(scp) }, null)
ObjProperty(
name,
object : Statement() {
override val pos: Pos = initExpr.pos
override suspend fun execute(scp: Scope): Obj = initExpr.execute(scp)
},
null
)
}
val type = context[extTypeName]?.value ?: context.raiseSymbolNotFound("class $extTypeName not found")
@ -3156,7 +3165,7 @@ class Compiler(
context.addExtension(type, name, ObjRecord(prop, isMutable = false, visibility = visibility, writeVisibility = setterVisibility, declaringClass = null, type = ObjRecord.Type.Property))
return@statement prop
return prop
}
// In true class bodies (not inside a function), store fields under a class-qualified key to support MI collisions
// Do NOT infer declaring class from runtime thisObj here; only the compile-time captured
@ -3189,12 +3198,18 @@ class Compiler(
isClosed = isClosed,
isOverride = isOverride
)
cls.instanceInitializers += statement(start) { scp ->
cls.instanceInitializers += object : Statement() {
override val pos: Pos = start
override suspend fun execute(scp: Scope): Obj {
val initValue = initialExpression!!.execute(scp)
val accessTypeStr = if (isMutable) "Var" else "Val"
val accessType = scp.resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr")
val finalDelegate = try {
initValue.invokeInstanceMethod(scp, "bind", Arguments(ObjString(name), accessType, scp.thisObj))
initValue.invokeInstanceMethod(
scp,
"bind",
Arguments(ObjString(name), accessType, scp.thisObj)
)
} catch (e: Exception) {
initValue
}
@ -3207,9 +3222,10 @@ class Compiler(
).apply {
delegate = finalDelegate
}
ObjVoid
return ObjVoid
}
return@statement ObjVoid
}
return ObjVoid
} else {
val initValue = initialExpression!!.execute(context)
val accessTypeStr = if (isMutable) "Var" else "Val"
@ -3227,7 +3243,7 @@ class Compiler(
isOverride = isOverride
)
rec.delegate = finalDelegate
return@statement finalDelegate
return finalDelegate
}
} else {
val initValue = initialExpression!!.execute(context)
@ -3246,7 +3262,7 @@ class Compiler(
isOverride = isOverride
)
rec.delegate = finalDelegate
return@statement finalDelegate
return finalDelegate
}
} else if (getter != null || setter != null) {
val declaringClassName = declaringClassNameCaptured!!
@ -3255,7 +3271,7 @@ class Compiler(
// If we are in class scope now (defining instance field), defer initialization to instance time
val isClassScope = context.thisObj is ObjClass && (context.thisObj !is ObjInstance)
if (isClassScope) {
return if (isClassScope) {
val cls = context.thisObj as ObjClass
// Register in class members for reflection/MRO/satisfaction checks
if (isProperty) {
@ -3284,7 +3300,9 @@ class Compiler(
// Register the property/field initialization thunk
if (!isAbstract) {
cls.instanceInitializers += statement(start) { scp ->
cls.instanceInitializers += object : Statement() {
override val pos: Pos = start
override suspend fun execute(scp: Scope): Obj {
scp.addItem(
storageName,
isMutable,
@ -3296,7 +3314,8 @@ class Compiler(
isClosed = isClosed,
isOverride = isOverride
)
ObjVoid
return ObjVoid
}
}
}
ObjVoid
@ -3313,7 +3332,7 @@ class Compiler(
}
} else {
val isLateInitVal = !isMutable && initialExpression == null && getter == null && setter == null
if (declaringClassName != null && !isStatic) {
return if (declaringClassName != null && !isStatic) {
val storageName = "$declaringClassName::$name"
// If we are in class scope now (defining instance field), defer initialization to instance time
val isClassScope = context.thisObj is ObjClass && (context.thisObj !is ObjInstance)
@ -3335,7 +3354,9 @@ class Compiler(
// Defer: at instance construction, evaluate initializer in instance scope and store under mangled name
if (!isAbstract) {
val initStmt = statement(start) { scp ->
val initStmt = object : Statement() {
override val pos: Pos = start
override suspend fun execute(scp: Scope): Obj {
val initValue =
initialExpression?.execute(scp)?.byValueCopy()
?: if (isLateInitVal) ObjUnset else ObjNull
@ -3347,7 +3368,8 @@ class Compiler(
isClosed = isClosed,
isOverride = isOverride
)
ObjVoid
return ObjVoid
}
}
cls.instanceInitializers += initStmt
}
@ -3376,6 +3398,8 @@ class Compiler(
}
}
}
data class Operator(
val tokenType: Token.Type,
val priority: Int, val arity: Int = 2,
@ -3682,4 +3706,3 @@ class Compiler(
}
suspend fun eval(code: String) = compile(code).execute()

View File

@ -625,9 +625,8 @@ open class Scope(
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
return del.invokeInstanceMethod(this, "getValue", Arguments(th, ObjString(name)), onNotFoundResult = {
// If getValue not found, return a wrapper that calls invoke
object : Statement() {
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
@ -635,7 +634,8 @@ open class Scope(
return del.invokeInstanceMethod(scope, "invoke", Arguments(*allArgs))
}
}
})!!
}
return del.invokeInstanceMethod(this, "getValue", Arguments(th, ObjString(name)))
}
return rec.value
}

View File

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