Compare commits

...

1 Commits

Author SHA1 Message Date
aec4a3766e wasm generation bug workaround, docs and debugging tips 2026-01-24 18:10:49 +03:00
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.skipWsTokens()
cc.next() // consume '=' cc.next() // consume '='
val expr = parseExpression() ?: throw ScriptError(cc.current().pos, "Expected getter expression") val expr = parseExpression() ?: throw ScriptError(cc.current().pos, "Expected getter expression")
(expr as? Statement) ?: statement(expr.pos) { s -> expr.execute(s) } expr
} else { } else {
throw ScriptError(cc.current().pos, "Expected { or = after get()") throw ScriptError(cc.current().pos, "Expected { or = after get()")
} }
@ -3074,7 +3074,7 @@ class Compiler(
cc.skipWsTokens() cc.skipWsTokens()
cc.next() // consume '=' cc.next() // consume '='
val expr = parseExpression() ?: throw ScriptError(cc.current().pos, "Expected setter expression") 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 -> statement(st.pos) { scope ->
val value = scope.args.list.firstOrNull() ?: ObjNull val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument) scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument)
@ -3109,7 +3109,7 @@ class Compiler(
cc.current().pos, cc.current().pos,
"Expected setter expression" "Expected setter expression"
) )
val st = (expr as? Statement) ?: statement(expr.pos) { s -> expr.execute(s) } val st = expr
statement(st.pos) { scope -> statement(st.pos) { scope ->
val value = scope.args.list.firstOrNull() ?: ObjNull val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument) 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) { if (extTypeName != null) {
val prop = if (getter != null || setter != null) { val prop = if (getter != null || setter != null) {
ObjProperty(name, getter, setter) ObjProperty(name, getter, setter)
} else { } else {
// Simple val extension with initializer // Simple val extension with initializer
val initExpr = initialExpression ?: throw ScriptError(start, "Extension val must be initialized") 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") 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)) 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 // 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 // Do NOT infer declaring class from runtime thisObj here; only the compile-time captured
@ -3189,27 +3198,34 @@ class Compiler(
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride isOverride = isOverride
) )
cls.instanceInitializers += statement(start) { scp -> cls.instanceInitializers += object : Statement() {
val initValue = initialExpression!!.execute(scp) override val pos: Pos = start
val accessTypeStr = if (isMutable) "Var" else "Val" override suspend fun execute(scp: Scope): Obj {
val accessType = scp.resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr") val initValue = initialExpression!!.execute(scp)
val finalDelegate = try { val accessTypeStr = if (isMutable) "Var" else "Val"
initValue.invokeInstanceMethod(scp, "bind", Arguments(ObjString(name), accessType, scp.thisObj)) val accessType = scp.resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr")
} catch (e: Exception) { val finalDelegate = try {
initValue initValue.invokeInstanceMethod(
scp,
"bind",
Arguments(ObjString(name), accessType, scp.thisObj)
)
} catch (e: Exception) {
initValue
}
scp.addItem(
storageName, isMutable, ObjUnset, visibility, setterVisibility,
recordType = ObjRecord.Type.Delegated,
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride
).apply {
delegate = finalDelegate
}
return ObjVoid
} }
scp.addItem(
storageName, isMutable, ObjUnset, visibility, setterVisibility,
recordType = ObjRecord.Type.Delegated,
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride
).apply {
delegate = finalDelegate
}
ObjVoid
} }
return@statement ObjVoid return ObjVoid
} else { } else {
val initValue = initialExpression!!.execute(context) val initValue = initialExpression!!.execute(context)
val accessTypeStr = if (isMutable) "Var" else "Val" val accessTypeStr = if (isMutable) "Var" else "Val"
@ -3227,7 +3243,7 @@ class Compiler(
isOverride = isOverride isOverride = isOverride
) )
rec.delegate = finalDelegate rec.delegate = finalDelegate
return@statement finalDelegate return finalDelegate
} }
} else { } else {
val initValue = initialExpression!!.execute(context) val initValue = initialExpression!!.execute(context)
@ -3246,7 +3262,7 @@ class Compiler(
isOverride = isOverride isOverride = isOverride
) )
rec.delegate = finalDelegate rec.delegate = finalDelegate
return@statement finalDelegate return finalDelegate
} }
} else if (getter != null || setter != null) { } else if (getter != null || setter != null) {
val declaringClassName = declaringClassNameCaptured!! val declaringClassName = declaringClassNameCaptured!!
@ -3255,7 +3271,7 @@ class Compiler(
// If we are in class scope now (defining instance field), defer initialization to instance time // 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) val isClassScope = context.thisObj is ObjClass && (context.thisObj !is ObjInstance)
if (isClassScope) { return if (isClassScope) {
val cls = context.thisObj as ObjClass val cls = context.thisObj as ObjClass
// Register in class members for reflection/MRO/satisfaction checks // Register in class members for reflection/MRO/satisfaction checks
if (isProperty) { if (isProperty) {
@ -3284,19 +3300,22 @@ class Compiler(
// Register the property/field initialization thunk // Register the property/field initialization thunk
if (!isAbstract) { if (!isAbstract) {
cls.instanceInitializers += statement(start) { scp -> cls.instanceInitializers += object : Statement() {
scp.addItem( override val pos: Pos = start
storageName, override suspend fun execute(scp: Scope): Obj {
isMutable, scp.addItem(
prop, storageName,
visibility, isMutable,
setterVisibility, prop,
recordType = ObjRecord.Type.Property, visibility,
isAbstract = isAbstract, setterVisibility,
isClosed = isClosed, recordType = ObjRecord.Type.Property,
isOverride = isOverride isAbstract = isAbstract,
) isClosed = isClosed,
ObjVoid isOverride = isOverride
)
return ObjVoid
}
} }
} }
ObjVoid ObjVoid
@ -3312,30 +3331,32 @@ class Compiler(
prop prop
} }
} else { } else {
val isLateInitVal = !isMutable && initialExpression == null && getter == null && setter == null val isLateInitVal = !isMutable && initialExpression == null && getter == null && setter == null
if (declaringClassName != null && !isStatic) { return if (declaringClassName != null && !isStatic) {
val storageName = "$declaringClassName::$name" val storageName = "$declaringClassName::$name"
// If we are in class scope now (defining instance field), defer initialization to instance time // 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) val isClassScope = context.thisObj is ObjClass && (context.thisObj !is ObjInstance)
if (isClassScope) { if (isClassScope) {
val cls = context.thisObj as ObjClass val cls = context.thisObj as ObjClass
// Register in class members for reflection/MRO/satisfaction checks // Register in class members for reflection/MRO/satisfaction checks
cls.createField( cls.createField(
name, name,
ObjNull, ObjNull,
isMutable = isMutable, isMutable = isMutable,
visibility = visibility, visibility = visibility,
writeVisibility = setterVisibility, writeVisibility = setterVisibility,
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
pos = start, pos = start,
type = ObjRecord.Type.Field type = ObjRecord.Type.Field
) )
// Defer: at instance construction, evaluate initializer in instance scope and store under mangled name // Defer: at instance construction, evaluate initializer in instance scope and store under mangled name
if (!isAbstract) { 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 = val initValue =
initialExpression?.execute(scp)?.byValueCopy() initialExpression?.execute(scp)?.byValueCopy()
?: if (isLateInitVal) ObjUnset else ObjNull ?: if (isLateInitVal) ObjUnset else ObjNull
@ -3347,35 +3368,38 @@ class Compiler(
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride isOverride = isOverride
) )
ObjVoid return ObjVoid
} }
cls.instanceInitializers += initStmt
} }
ObjVoid cls.instanceInitializers += initStmt
} else {
// We are in instance scope already: perform initialization immediately
val initValue =
initialExpression?.execute(context)?.byValueCopy() ?: if (isLateInitVal) ObjUnset else ObjNull
// Preserve mutability of declaration: create record with correct mutability
context.addItem(
storageName, isMutable, initValue, visibility, setterVisibility,
recordType = ObjRecord.Type.Field,
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride
)
initValue
} }
ObjVoid
} else { } else {
// Not in class body: regular local/var declaration // We are in instance scope already: perform initialization immediately
val initValue = initialExpression?.execute(context)?.byValueCopy() ?: ObjNull val initValue =
context.addItem(name, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) initialExpression?.execute(context)?.byValueCopy() ?: if (isLateInitVal) ObjUnset else ObjNull
// Preserve mutability of declaration: create record with correct mutability
context.addItem(
storageName, isMutable, initValue, visibility, setterVisibility,
recordType = ObjRecord.Type.Field,
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride
)
initValue initValue
} }
} else {
// Not in class body: regular local/var declaration
val initValue = initialExpression?.execute(context)?.byValueCopy() ?: ObjNull
context.addItem(name, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field)
initValue
}
} }
} }
} }
}
data class Operator( data class Operator(
val tokenType: Token.Type, val tokenType: Token.Type,
val priority: Int, val arity: Int = 2, val priority: Int, val arity: Int = 2,
@ -3682,4 +3706,3 @@ class Compiler(
} }
suspend fun eval(code: String) = compile(code).execute() suspend fun eval(code: String) = compile(code).execute()

View File

@ -625,9 +625,8 @@ open class Scope(
if (rec.type == ObjRecord.Type.Delegated) { if (rec.type == ObjRecord.Type.Delegated) {
val del = rec.delegate ?: raiseError("Internal error: delegated property $name has no delegate") val del = rec.delegate ?: raiseError("Internal error: delegated property $name has no delegate")
val th = if (thisObj === ObjVoid) ObjNull else thisObj val th = if (thisObj === ObjVoid) ObjNull else thisObj
return del.invokeInstanceMethod(this, "getValue", Arguments(th, ObjString(name)), onNotFoundResult = { if (del.objClass.getInstanceMemberOrNull("getValue") == null) {
// If getValue not found, return a wrapper that calls invoke return object : Statement() {
object : Statement() {
override val pos: Pos = Pos.builtIn override val pos: Pos = Pos.builtIn
override suspend fun execute(scope: Scope): Obj { override suspend fun execute(scope: Scope): Obj {
val th2 = if (scope.thisObj === ObjVoid) ObjNull else scope.thisObj 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(scope, "invoke", Arguments(*allArgs))
} }
} }
})!! }
return del.invokeInstanceMethod(this, "getValue", Arguments(th, ObjString(name)))
} }
return rec.value return rec.value
} }

View File

@ -442,16 +442,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(