Compare commits

..

2 Commits

6 changed files with 140 additions and 51 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) - [Samples directory](docs/samples)
- [Formatter (core + CLI + IDE)](docs/formatter.md) - [Formatter (core + CLI + IDE)](docs/formatter.md)
- [Books directory](docs) - [Books directory](docs)
- [AI agent guidance](AGENTS.md)
## Integration in Kotlin multiplatform ## Integration in Kotlin multiplatform

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

@ -3267,37 +3267,60 @@ class Compiler(
// when creating instance, but we need to execute it in the class initializer which // when creating instance, but we need to execute it in the class initializer which
// is missing as for now. Add it to the compiler context? // is missing as for now. Add it to the compiler context?
currentInitScope += statement { currentInitScope += object : Statement() {
val initValue = initialExpression?.execute(this)?.byValueCopy() ?: ObjNull override val pos: Pos = start
if (isDelegate) { override suspend fun execute(scope: Scope): Obj {
val accessTypeStr = if (isMutable) "Var" else "Val" val initValue = initialExpression?.execute(scope)?.byValueCopy() ?: ObjNull
val accessType = resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr") if (isDelegate) {
val finalDelegate = try { val accessTypeStr = if (isMutable) "Var" else "Val"
initValue.invokeInstanceMethod(this, "bind", Arguments(ObjString(name), accessType, thisObj)) val accessType = scope.resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr")
} catch (e: Exception) { val finalDelegate = try {
initValue initValue.invokeInstanceMethod(
scope,
"bind",
Arguments(ObjString(name), accessType, scope.thisObj)
)
} catch (e: Exception) {
initValue
}
(scope.thisObj as ObjClass).createClassField(
name,
ObjUnset,
isMutable,
visibility,
null,
start,
isTransient = isTransient,
type = ObjRecord.Type.Delegated
).apply {
delegate = finalDelegate
}
// Also expose in current init scope
scope.addItem(
name,
isMutable,
ObjUnset,
visibility,
null,
ObjRecord.Type.Delegated,
isTransient = isTransient
).apply {
delegate = finalDelegate
}
} else {
(scope.thisObj as ObjClass).createClassField(
name,
initValue,
isMutable,
visibility,
null,
start,
isTransient = isTransient
)
scope.addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field, isTransient = isTransient)
} }
(thisObj as ObjClass).createClassField( return ObjVoid
name,
ObjUnset,
isMutable,
visibility,
null,
start,
isTransient = isTransient,
type = ObjRecord.Type.Delegated
).apply {
delegate = finalDelegate
}
// Also expose in current init scope
addItem(name, isMutable, ObjUnset, visibility, null, ObjRecord.Type.Delegated, isTransient = isTransient).apply {
delegate = finalDelegate
}
} else {
(thisObj as ObjClass).createClassField(name, initValue, isMutable, visibility, null, start, isTransient = isTransient)
addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field, isTransient = isTransient)
} }
ObjVoid
} }
return NopStatement return NopStatement
} }
@ -3349,10 +3372,13 @@ class Compiler(
val body = inCodeContext(CodeContext.Function("<setter>")) { val body = inCodeContext(CodeContext.Function("<setter>")) {
parseBlock() parseBlock()
} }
statement(body.pos) { scope -> object : Statement() {
val value = scope.args.list.firstOrNull() ?: ObjNull override val pos: Pos = body.pos
scope.addItem(setArgName, true, value, recordType = ObjRecord.Type.Argument) override suspend fun execute(scope: Scope): Obj {
body.execute(scope) val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArgName, true, value, recordType = ObjRecord.Type.Argument)
return body.execute(scope)
}
} }
} else if (cc.peekNextNonWhitespace().type == Token.Type.ASSIGN) { } else if (cc.peekNextNonWhitespace().type == Token.Type.ASSIGN) {
cc.skipWsTokens() cc.skipWsTokens()
@ -3362,10 +3388,13 @@ class Compiler(
?: throw ScriptError(cc.current().pos, "Expected setter expression") ?: throw ScriptError(cc.current().pos, "Expected setter expression")
} }
val st = expr val st = expr
statement(st.pos) { scope -> object : Statement() {
val value = scope.args.list.firstOrNull() ?: ObjNull override val pos: Pos = st.pos
scope.addItem(setArgName, true, value, recordType = ObjRecord.Type.Argument) override suspend fun execute(scope: Scope): Obj {
st.execute(scope) val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArgName, true, value, recordType = ObjRecord.Type.Argument)
return st.execute(scope)
}
} }
} else { } else {
throw ScriptError(cc.current().pos, "Expected { or = after set(...)") throw ScriptError(cc.current().pos, "Expected { or = after set(...)")
@ -3388,10 +3417,13 @@ class Compiler(
val body = inCodeContext(CodeContext.Function("<setter>")) { val body = inCodeContext(CodeContext.Function("<setter>")) {
parseBlock() parseBlock()
} }
statement(body.pos) { scope -> object : Statement() {
val value = scope.args.list.firstOrNull() ?: ObjNull override val pos: Pos = body.pos
scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument) override suspend fun execute(scope: Scope): Obj {
body.execute(scope) val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument)
return body.execute(scope)
}
} }
} else if (cc.peekNextNonWhitespace().type == Token.Type.ASSIGN) { } else if (cc.peekNextNonWhitespace().type == Token.Type.ASSIGN) {
cc.skipWsTokens() cc.skipWsTokens()
@ -3402,10 +3434,13 @@ class Compiler(
"Expected setter expression" "Expected setter expression"
) )
} }
statement(st.pos) { scope -> object : Statement() {
val value = scope.args.list.firstOrNull() ?: ObjNull override val pos: Pos = st.pos
scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument) override suspend fun execute(scope: Scope): Obj {
st.execute(scope) val value = scope.args.list.firstOrNull() ?: ObjNull
scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument)
return st.execute(scope)
}
} }
} else { } else {
throw ScriptError(cc.current().pos, "Expected { or = after set(...)") throw ScriptError(cc.current().pos, "Expected { or = after set(...)")
@ -3994,4 +4029,3 @@ class Compiler(
suspend fun eval(code: String) = compile(code).execute() suspend fun eval(code: String) = compile(code).execute()
suspend fun evalNamed(name: String, code: String, importManager: ImportManager = Script.defaultImportManager) = suspend fun evalNamed(name: String, code: String, importManager: ImportManager = Script.defaultImportManager) =
compile(Source(name,code), importManager).execute() compile(Source(name,code), importManager).execute()

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